From 4c18d3bdc20fa2cebe82e74bd025917196f5aad4 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 9 Sep 2026 14:34:37 -0700 Subject: [PATCH 01/19] gh-122622: Add PyREPL pre-execution hook Co-authored-by: Anthony Kim --- Lib/_pyrepl/simple_interact.py | 9 ++++ Lib/test/test_pyrepl/test_interact.py | 67 ++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index e6c355388a7c074..d7f864d20f43db8 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -30,6 +30,8 @@ import code import warnings +import _pyrepl + from .readline import _get_reader, multiline_input, append_history_file @@ -145,6 +147,13 @@ def maybe_run_command(statement: str) -> bool: if maybe_run_command(statement): continue + pre_execution_hook = getattr(_pyrepl, "pre_execution_hook", None) + if callable(pre_execution_hook): + try: + pre_execution_hook(statement) + except Exception: + pass + input_name = f"" more = console.push(_strip_final_indent(statement), filename=input_name, _symbol="single") # type: ignore[call-arg] assert not more diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index fd4530ebc004aa7..0a896df7883e595 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -2,9 +2,11 @@ import io import warnings import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch from textwrap import dedent +import _pyrepl + from test.support import force_not_colorized from _pyrepl.console import InteractiveColoredConsole @@ -299,3 +301,66 @@ def f(): count = sum("'return' in a 'finally' block" in str(w.message) for w in caught) self.assertEqual(count, 1) + + +class TestPreExecutionHook(unittest.TestCase): + + def _run_interactive(self, statements, *, pre_execution_hook=None): + from _pyrepl.simple_interact import run_multiline_interactive_console + + console = InteractiveColoredConsole() + statement_iter = iter(statements) + + def fake_multiline_input(more_lines, ps1, ps2): + try: + return next(statement_iter) + except StopIteration: + raise EOFError + + patches = [ + patch( + "_pyrepl.simple_interact.multiline_input", + side_effect=fake_multiline_input, + ), + patch("_pyrepl.simple_interact._get_reader"), + patch("_pyrepl.simple_interact.append_history_file"), + patch("_pyrepl.readline._setup"), + ] + if pre_execution_hook is not None: + patches.append( + patch.object( + _pyrepl, + "pre_execution_hook", + pre_execution_hook, + create=True, + ) + ) + + output = io.StringIO() + with contextlib.ExitStack() as stack: + for context_manager in patches: + stack.enter_context(context_manager) + stack.enter_context(contextlib.redirect_stdout(output)) + stack.enter_context(contextlib.redirect_stderr(output)) + run_multiline_interactive_console(console) + + return output.getvalue() + + def test_hook_called_with_statement(self): + hook = MagicMock() + self._run_interactive(["x = 1"], pre_execution_hook=hook) + hook.assert_called_once_with("x = 1") + + def test_hook_exception_does_not_break_repl(self): + def bad_hook(command): + raise RuntimeError("hook error") + + self._run_interactive( + ["x = 1", "y = 2"], + pre_execution_hook=bad_hook, + ) + + def test_hook_not_called_for_repl_commands(self): + hook = MagicMock() + self._run_interactive(["clear"], pre_execution_hook=hook) + hook.assert_not_called() From b988e6f700cea2b922789924e838780e20d9e3f5 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 9 Sep 2026 15:51:33 -0700 Subject: [PATCH 02/19] Add empty pre_execution_hook for feature detection --- Lib/_pyrepl/__init__.py | 6 ++++++ Lib/test/test_pyrepl/test_interact.py | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/__init__.py b/Lib/_pyrepl/__init__.py index 1693cbd0b98b74c..6b2328893d92441 100644 --- a/Lib/_pyrepl/__init__.py +++ b/Lib/_pyrepl/__init__.py @@ -17,3 +17,9 @@ # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from collections.abc import Callable as _Callable + + +# Declared here so external tools can detect support and install a callback. +pre_execution_hook: _Callable[[str], object] | None = None diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index 0a896df7883e595..ffdaec89877196f 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -305,6 +305,9 @@ def f(): class TestPreExecutionHook(unittest.TestCase): + def test_hook_is_unset_by_default(self): + self.assertIsNone(_pyrepl.pre_execution_hook) + def _run_interactive(self, statements, *, pre_execution_hook=None): from _pyrepl.simple_interact import run_multiline_interactive_console @@ -332,7 +335,6 @@ def fake_multiline_input(more_lines, ps1, ps2): _pyrepl, "pre_execution_hook", pre_execution_hook, - create=True, ) ) From 7d9ddbfe93b02e0e82b4f8a3aa5e5d2a7ba1108e Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 9 Sep 2026 15:57:13 -0700 Subject: [PATCH 03/19] Update tests to verify no interference --- Lib/test/test_pyrepl/test_interact.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index ffdaec89877196f..d5ba6a30ae97938 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -346,7 +346,7 @@ def fake_multiline_input(more_lines, ps1, ps2): stack.enter_context(contextlib.redirect_stderr(output)) run_multiline_interactive_console(console) - return output.getvalue() + return output.getvalue(), console.locals def test_hook_called_with_statement(self): hook = MagicMock() @@ -354,13 +354,15 @@ def test_hook_called_with_statement(self): hook.assert_called_once_with("x = 1") def test_hook_exception_does_not_break_repl(self): - def bad_hook(command): - raise RuntimeError("hook error") - - self._run_interactive( + hook = MagicMock(side_effect=RuntimeError("hook error")) + output, namespace = self._run_interactive( ["x = 1", "y = 2"], - pre_execution_hook=bad_hook, + pre_execution_hook=hook, ) + self.assertEqual(hook.call_count, 2) + self.assertEqual(namespace["x"], 1) + self.assertEqual(namespace["y"], 2) + self.assertNotIn("hook error", output) def test_hook_not_called_for_repl_commands(self): hook = MagicMock() From ad17396240c154f653fb791696bd507d6efa6f75 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:15:05 +0000 Subject: [PATCH 04/19] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20b?= =?UTF-8?q?lurb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst diff --git a/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst b/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst new file mode 100644 index 000000000000000..d9dd4200577639d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst @@ -0,0 +1,3 @@ +Add an optional ``_pyrepl.pre_execution_hook`` callable that is invoked with +the submitted source before each interactive statement is executed. This +allows external tools to implement features such as shell integration. From 0ef602cf42bee64714b25d8534e298fbccc2f72e Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 9 Sep 2026 16:23:36 -0700 Subject: [PATCH 05/19] Update NEWS --- .../Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst b/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst index d9dd4200577639d..c73e9e45c01454c 100644 --- a/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst +++ b/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst @@ -1,3 +1,3 @@ -Add an optional ``_pyrepl.pre_execution_hook`` callable that is invoked with -the submitted source before each interactive statement is executed. This +Add an optional ``_pyrepl.pre_execution_hook`` callable that is invoked with +the submitted source before each interactive statement is executed. This allows external tools to implement features such as shell integration. From 8ede9b25b7a87032e155f4c30c3bda7beebfaa78 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Fri, 18 Sep 2026 13:27:11 -0700 Subject: [PATCH 06/19] Address feedback --- Lib/_pyrepl/__init__.py | 6 --- Lib/_pyrepl/simple_interact.py | 23 +++++++-- Lib/test/test_pyrepl/test_interact.py | 72 ++++++++++----------------- 3 files changed, 44 insertions(+), 57 deletions(-) diff --git a/Lib/_pyrepl/__init__.py b/Lib/_pyrepl/__init__.py index 6b2328893d92441..1693cbd0b98b74c 100644 --- a/Lib/_pyrepl/__init__.py +++ b/Lib/_pyrepl/__init__.py @@ -17,9 +17,3 @@ # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -from collections.abc import Callable as _Callable - - -# Declared here so external tools can detect support and install a callback. -pre_execution_hook: _Callable[[str], object] | None = None diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index d7f864d20f43db8..9bf36c012d00381 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -30,11 +30,25 @@ import code import warnings -import _pyrepl - from .readline import _get_reader, multiline_input, append_history_file +# Called after the user submits a Python statement, but before it is executed. +# The hook is not called for PyREPL commands such as ``clear``. Exceptions raised +# by the hook and its return value are ignored. External tools such as IDEs can +# install a hook to augment the behavior of the REPL. +# +# For example, VS Code can mark the start of command execution: +# +# from _pyrepl import simple_interact +# +# def vscode_statement_submitted(statement: str) -> None: +# print("\x1b]633;C\x07", end="") +# +# simple_interact.statement_submitted_hook = vscode_statement_submitted +statement_submitted_hook = None + + _error: tuple[type[Exception], ...] | type[Exception] try: from .unix_console import _error @@ -147,10 +161,9 @@ def maybe_run_command(statement: str) -> bool: if maybe_run_command(statement): continue - pre_execution_hook = getattr(_pyrepl, "pre_execution_hook", None) - if callable(pre_execution_hook): + if statement_submitted_hook is not None: try: - pre_execution_hook(statement) + statement_submitted_hook(statement) except Exception: pass diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index d5ba6a30ae97938..0d192f0abe1b19e 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -5,10 +5,9 @@ from unittest.mock import MagicMock, patch from textwrap import dedent -import _pyrepl - from test.support import force_not_colorized +from _pyrepl import simple_interact from _pyrepl.console import InteractiveColoredConsole from _pyrepl.simple_interact import _more_lines @@ -303,14 +302,9 @@ def f(): self.assertEqual(count, 1) -class TestPreExecutionHook(unittest.TestCase): - - def test_hook_is_unset_by_default(self): - self.assertIsNone(_pyrepl.pre_execution_hook) - - def _run_interactive(self, statements, *, pre_execution_hook=None): - from _pyrepl.simple_interact import run_multiline_interactive_console +class TestStatementSubmittedHook(unittest.TestCase): + def _run_interactive(self, statements, hook): console = InteractiveColoredConsole() statement_iter = iter(statements) @@ -320,51 +314,37 @@ def fake_multiline_input(more_lines, ps1, ps2): except StopIteration: raise EOFError - patches = [ - patch( - "_pyrepl.simple_interact.multiline_input", + output = io.StringIO() + with ( + patch.object(simple_interact, "statement_submitted_hook", hook), + patch.object( + simple_interact, + "multiline_input", side_effect=fake_multiline_input, ), - patch("_pyrepl.simple_interact._get_reader"), - patch("_pyrepl.simple_interact.append_history_file"), + patch.object(simple_interact, "_get_reader"), + patch.object(simple_interact, "append_history_file"), patch("_pyrepl.readline._setup"), - ] - if pre_execution_hook is not None: - patches.append( - patch.object( - _pyrepl, - "pre_execution_hook", - pre_execution_hook, - ) - ) - - output = io.StringIO() - with contextlib.ExitStack() as stack: - for context_manager in patches: - stack.enter_context(context_manager) - stack.enter_context(contextlib.redirect_stdout(output)) - stack.enter_context(contextlib.redirect_stderr(output)) - run_multiline_interactive_console(console) + contextlib.redirect_stdout(output), + contextlib.redirect_stderr(output), + ): + simple_interact.run_multiline_interactive_console(console) return output.getvalue(), console.locals - def test_hook_called_with_statement(self): - hook = MagicMock() - self._run_interactive(["x = 1"], pre_execution_hook=hook) - hook.assert_called_once_with("x = 1") - - def test_hook_exception_does_not_break_repl(self): - hook = MagicMock(side_effect=RuntimeError("hook error")) - output, namespace = self._run_interactive( - ["x = 1", "y = 2"], - pre_execution_hook=hook, + def test_hook_called_before_statement_execution(self): + statement = "print('statement executed')" + escape_sequence = "\x1b]633;C\x07" + hook = MagicMock( + side_effect=lambda statement: print(escape_sequence, end="") ) - self.assertEqual(hook.call_count, 2) - self.assertEqual(namespace["x"], 1) - self.assertEqual(namespace["y"], 2) - self.assertNotIn("hook error", output) + output, _ = self._run_interactive([statement], hook) + + hook.assert_called_once_with(statement) + self.assertEqual(output, f"{escape_sequence}statement executed\n") def test_hook_not_called_for_repl_commands(self): hook = MagicMock() - self._run_interactive(["clear"], pre_execution_hook=hook) + self._run_interactive(["clear"], hook) + hook.assert_not_called() From 6d7dfe0f41bd65765d02af68a39134569430920d Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Fri, 18 Sep 2026 17:54:25 -0700 Subject: [PATCH 07/19] Address feedback --- Lib/_pyrepl/simple_interact.py | 9 +++++---- Lib/test/test_pyrepl/test_interact.py | 2 +- .../2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst | 6 +++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index 9bf36c012d00381..40769570a25d3e5 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -33,10 +33,11 @@ from .readline import _get_reader, multiline_input, append_history_file -# Called after the user submits a Python statement, but before it is executed. -# The hook is not called for PyREPL commands such as ``clear``. Exceptions raised -# by the hook and its return value are ignored. External tools such as IDEs can -# install a hook to augment the behavior of the REPL. +# Called with the complete statement, which may span multiple lines, after the +# user submits it but before it is executed. The hook is not called for PyREPL +# commands such as ``clear``. Exceptions raised by the hook and its return value +# are ignored. External tools such as IDEs can install a hook to augment the +# behavior of the REPL. # # For example, VS Code can mark the start of command execution: # diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index 0d192f0abe1b19e..aa3a40c6be7bca3 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -333,7 +333,7 @@ def fake_multiline_input(more_lines, ps1, ps2): return output.getvalue(), console.locals def test_hook_called_before_statement_execution(self): - statement = "print('statement executed')" + statement = "if True:\n print('statement executed')\n" escape_sequence = "\x1b]633;C\x07" hook = MagicMock( side_effect=lambda statement: print(escape_sequence, end="") diff --git a/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst b/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst index c73e9e45c01454c..e0101669497706a 100644 --- a/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst +++ b/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst @@ -1,3 +1,3 @@ -Add an optional ``_pyrepl.pre_execution_hook`` callable that is invoked with -the submitted source before each interactive statement is executed. This -allows external tools to implement features such as shell integration. +Add an optional ``_pyrepl.simple_interact.statement_submitted_hook`` callback +that runs after a complete statement is submitted and before it is executed, +allowing external tools to implement features such as shell integration. From e1186d5a306f956036e6a654477f5990d3dd39f0 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 21 Sep 2026 14:43:44 -0700 Subject: [PATCH 08/19] Show tracebakcs --- Lib/_pyrepl/simple_interact.py | 9 +++++---- Lib/test/test_pyrepl/test_interact.py | 9 +++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index 40769570a25d3e5..cf4613059317e6f 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -35,9 +35,10 @@ # Called with the complete statement, which may span multiple lines, after the # user submits it but before it is executed. The hook is not called for PyREPL -# commands such as ``clear``. Exceptions raised by the hook and its return value -# are ignored. External tools such as IDEs can install a hook to augment the -# behavior of the REPL. +# commands such as ``clear``. Exceptions raised by the hook are displayed but +# do not prevent the statement from running. Its return value is ignored. +# External tools such as IDEs can install a hook to augment the behavior of the +# REPL. # # For example, VS Code can mark the start of command execution: # @@ -166,7 +167,7 @@ def maybe_run_command(statement: str) -> bool: try: statement_submitted_hook(statement) except Exception: - pass + console.showtraceback() input_name = f"" more = console.push(_strip_final_indent(statement), filename=input_name, _symbol="single") # type: ignore[call-arg] diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index aa3a40c6be7bca3..83fa6c0b60acbae 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -343,6 +343,15 @@ def test_hook_called_before_statement_execution(self): hook.assert_called_once_with(statement) self.assertEqual(output, f"{escape_sequence}statement executed\n") + @force_not_colorized + def test_hook_exception_is_displayed(self): + hook = MagicMock(side_effect=RuntimeError("hook error")) + + output, namespace = self._run_interactive(["x = 1"], hook) + + self.assertIn("RuntimeError: hook error", output) + self.assertEqual(namespace["x"], 1) + def test_hook_not_called_for_repl_commands(self): hook = MagicMock() self._run_interactive(["clear"], hook) From f615af20aaf30907002b962ec1591b1ba0b993e9 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 23 Sep 2026 14:58:18 -0700 Subject: [PATCH 09/19] Add tests to make sure hook runs before exception --- Lib/test/test_pyrepl/test_interact.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index 83fa6c0b60acbae..0e9dd00694de865 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -303,7 +303,6 @@ def f(): class TestStatementSubmittedHook(unittest.TestCase): - def _run_interactive(self, statements, hook): console = InteractiveColoredConsole() statement_iter = iter(statements) @@ -352,8 +351,27 @@ def test_hook_exception_is_displayed(self): self.assertIn("RuntimeError: hook error", output) self.assertEqual(namespace["x"], 1) + def test_hook_return_value_is_ignored(self): + hook = MagicMock(return_value=True) + + _, namespace = self._run_interactive(["x = 1"], hook) + + self.assertEqual(namespace["x"], 1) + def test_hook_not_called_for_repl_commands(self): hook = MagicMock() self._run_interactive(["clear"], hook) hook.assert_not_called() + + @force_not_colorized + def test_hook_called_before_execution_exception(self): + statement = "1 / 0" + marker = "hook ran first" + hook = MagicMock(side_effect=lambda statement: print(marker)) + output, _ = self._run_interactive([statement], hook) + + hook.assert_called_once_with(statement) + marker_index = output.index(marker) + traceback_index = output.index("ZeroDivisionError") + self.assertLess(marker_index, traceback_index) From f766b486a70b7243b5ac52fbdd4388fe27d18134 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 13 Oct 2026 14:50:06 -0700 Subject: [PATCH 10/19] Update comment --- Lib/_pyrepl/simple_interact.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index cf4613059317e6f..09b04e31c9c4f91 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -40,14 +40,16 @@ # External tools such as IDEs can install a hook to augment the behavior of the # REPL. # -# For example, VS Code can mark the start of command execution: +# For example, an IDE can mark the start of command execution: # # from _pyrepl import simple_interact # -# def vscode_statement_submitted(statement: str) -> None: -# print("\x1b]633;C\x07", end="") +# simple_interact.statement_submitted_hook = lambda x: print("\x1b]633;C\x07", end="") # -# simple_interact.statement_submitted_hook = vscode_statement_submitted +# In the REPL: +# >>> print("Hello, world!") +# \x1b]633;C\x07 +# Hello, world! statement_submitted_hook = None From a81c71abe41d0c79cd8479497f87acf3d16b1b95 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 13 Oct 2026 14:51:50 -0700 Subject: [PATCH 11/19] Fix incorrect comment --- Lib/_pyrepl/simple_interact.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index 09b04e31c9c4f91..14a7e7e7d19dd0f 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -48,8 +48,7 @@ # # In the REPL: # >>> print("Hello, world!") -# \x1b]633;C\x07 -# Hello, world! +# \x1b]633;C\x07Hello, world! statement_submitted_hook = None From ce55702d0996be48af0215afa5252d334ffb02b4 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 3 Nov 2026 10:46:42 -0800 Subject: [PATCH 12/19] Strip final indent from submitted hook --- Lib/_pyrepl/simple_interact.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index 02f52759b18ef66..f528a1edc3b1e15 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -171,7 +171,7 @@ def maybe_run_command(statement: str) -> bool: if statement_submitted_hook is not None: try: - statement_submitted_hook(statement) + statement_submitted_hook(_strip_final_indent(statement)) except Exception: console.showtraceback() From 866576906ce742246396097772fd151fb1fd0457 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 3 Nov 2026 13:14:31 -0800 Subject: [PATCH 13/19] Show traceback as a hook failure without modifying sys.last_* --- Lib/_pyrepl/simple_interact.py | 10 ++-- Lib/test/test_pyrepl/test_interact.py | 51 ++++++++++++++++++- ...-09-09-23-15-03.gh-issue-122622.YfSSrl.rst | 1 + 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index f528a1edc3b1e15..000d193abe6da54 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -35,8 +35,9 @@ # Called with the complete statement, which may span multiple lines, after the # user submits it but before it is executed. The hook is not called for PyREPL -# commands such as ``clear``. Exceptions raised by the hook are displayed but -# do not prevent the statement from running. Its return value is ignored. +# commands such as ``clear``. Exceptions raised by the hook are labelled and +# displayed without changing sys.last_* or preventing the statement from running. +# Its return value is ignored. # External tools such as IDEs can install a hook to augment the behavior of the # REPL. # @@ -172,8 +173,9 @@ def maybe_run_command(statement: str) -> bool: if statement_submitted_hook is not None: try: statement_submitted_hook(_strip_final_indent(statement)) - except Exception: - console.showtraceback() + except Exception as exc: + console.write("Error in statement_submitted_hook:\n") + console._excepthook(type(exc), exc, exc.__traceback__) input_name = f"" more = console.push(_strip_final_indent(statement), filename=input_name, _symbol="single") # type: ignore[call-arg] diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index 2aace4b240fee5d..83aa864308a1059 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -6,7 +6,7 @@ from unittest.mock import ANY, MagicMock, patch from textwrap import dedent -from test.support import force_not_colorized +from test.support import force_colorized, force_not_colorized from _pyrepl import simple_interact from _pyrepl.console import InteractiveColoredConsole @@ -407,9 +407,58 @@ def test_hook_exception_is_displayed(self): output, namespace = self._run_interactive(["x = 1"], hook) + self.assertIn( + "Error in statement_submitted_hook:\n" + "Traceback (most recent call last):\n", + output, + ) + self.assertIn("statement_submitted_hook(", output) self.assertIn("RuntimeError: hook error", output) self.assertEqual(namespace["x"], 1) + @force_colorized + def test_hook_exception_is_colorized(self): + def hook(statement): + raise RuntimeError("hook error") + + output, namespace = self._run_interactive(["x = 1"], hook) + + self.assertIn("Error in statement_submitted_hook:\n", output) + self.assertRegex(output, r"\x1b\[[0-9;]+m") + self.assertIn("hook error", output) + self.assertEqual(namespace["x"], 1) + + @force_not_colorized + def test_hook_exception_preserves_last_exception(self): + try: + raise ValueError("previous user error") + except ValueError as exc: + previous_exception = exc + + last_attributes = { + "last_exc": previous_exception, + "last_type": type(previous_exception), + "last_value": previous_exception, + "last_traceback": previous_exception.__traceback__, + } + for initialized in (True, False): + with self.subTest(initialized=initialized), contextlib.ExitStack() as stack: + for name, value in last_attributes.items(): + stack.enter_context(patch.object(sys, name, value, create=True)) + if not initialized: + delattr(sys, name) + + hook = MagicMock(side_effect=RuntimeError("hook error")) + output, namespace = self._run_interactive(["x = 1"], hook) + + self.assertIn("RuntimeError: hook error", output) + self.assertEqual(namespace["x"], 1) + for name, value in last_attributes.items(): + if initialized: + self.assertIs(getattr(sys, name), value) + else: + self.assertFalse(hasattr(sys, name)) + def test_hook_return_value_is_ignored(self): hook = MagicMock(return_value=True) diff --git a/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst b/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst index e0101669497706a..e9d68b74a6afaa3 100644 --- a/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst +++ b/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst @@ -1,3 +1,4 @@ Add an optional ``_pyrepl.simple_interact.statement_submitted_hook`` callback that runs after a complete statement is submitted and before it is executed, allowing external tools to implement features such as shell integration. +Hook failures are reported without replacing the last user exception. From b7e9986fc46e2913a32cf2e68841214be3301ebd Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 3 Nov 2026 13:32:14 -0800 Subject: [PATCH 14/19] Simplify comment to remove example --- Lib/_pyrepl/simple_interact.py | 21 ++++--------------- ...-09-09-23-15-03.gh-issue-122622.YfSSrl.rst | 2 +- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index 000d193abe6da54..45787d4f49ba6c7 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -33,23 +33,10 @@ from .readline import _get_reader, multiline_input, append_history_file -# Called with the complete statement, which may span multiple lines, after the -# user submits it but before it is executed. The hook is not called for PyREPL -# commands such as ``clear``. Exceptions raised by the hook are labelled and -# displayed without changing sys.last_* or preventing the statement from running. -# Its return value is ignored. -# External tools such as IDEs can install a hook to augment the behavior of the -# REPL. -# -# For example, an IDE can mark the start of command execution: -# -# from _pyrepl import simple_interact -# -# simple_interact.statement_submitted_hook = lambda x: print("\x1b]633;C\x07", end="") -# -# In the REPL: -# >>> print("Hello, world!") -# \x1b]633;C\x07Hello, world! +# Called with submitted input before execution, except for REPL commands. +# Final auto-indentation is removed; the return value is ignored. +# Ordinary exceptions are labelled and displayed without changing sys.last_* or +# stopping execution. KeyboardInterrupt cancels submission; SystemExit exits. statement_submitted_hook = None diff --git a/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst b/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst index e9d68b74a6afaa3..ab0cc21eadc3558 100644 --- a/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst +++ b/Misc/NEWS.d/next/Library/2026-09-09-23-15-03.gh-issue-122622.YfSSrl.rst @@ -1,4 +1,4 @@ Add an optional ``_pyrepl.simple_interact.statement_submitted_hook`` callback -that runs after a complete statement is submitted and before it is executed, +that runs after input is submitted and before it is executed, allowing external tools to implement features such as shell integration. Hook failures are reported without replacing the last user exception. From 753ed835c8c144c2e0329b5e40c0656d43051f86 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 3 Nov 2026 13:40:20 -0800 Subject: [PATCH 15/19] Add tests for KeyboardInterrupt and SystemExit --- Lib/test/test_pyrepl/test_interact.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index 83aa864308a1059..d0fcbb8c7b766d7 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -459,6 +459,27 @@ def test_hook_exception_preserves_last_exception(self): else: self.assertFalse(hasattr(sys, name)) + def test_hook_keyboard_interrupt_cancels_submission(self): + hook = MagicMock(side_effect=[KeyboardInterrupt, None]) + + output, namespace = self._run_interactive(["x = 1", "y = 2"], hook) + + self.assertIn("\nKeyboardInterrupt\n", output) + self.assertNotIn("Error in statement_submitted_hook:", output) + self.assertNotIn("x", namespace) + self.assertEqual(namespace["y"], 2) + self.assertEqual(hook.call_count, 2) + + def test_hook_system_exit_exits_repl(self): + hook = MagicMock(side_effect=SystemExit(42)) + statement = "raise SystemExit(99)" + + with self.assertRaises(SystemExit) as caught: + self._run_interactive([statement, "x = 1"], hook) + + self.assertEqual(caught.exception.code, 42) + hook.assert_called_once_with(statement) + def test_hook_return_value_is_ignored(self): hook = MagicMock(return_value=True) From d2a7a45156163b561c71c86393fd721c6cd29eb2 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 3 Nov 2026 13:45:58 -0800 Subject: [PATCH 16/19] Add test for strip indentation --- Lib/test/test_pyrepl/test_interact.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index d0fcbb8c7b766d7..a09294ccecf21bc 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -401,6 +401,15 @@ def test_hook_called_before_statement_execution(self): hook.assert_called_once_with(statement) self.assertEqual(output, f"{escape_sequence}statement executed\n") + def test_hook_receives_input_without_final_auto_indent(self): + statement = "if True:\n x = 1\n " + hook = MagicMock() + + _, namespace = self._run_interactive([statement], hook) + + hook.assert_called_once_with("if True:\n x = 1\n") + self.assertEqual(namespace["x"], 1) + @force_not_colorized def test_hook_exception_is_displayed(self): hook = MagicMock(side_effect=RuntimeError("hook error")) From ac8d177f5c262ef1e72b4c99dbd0c46ab121682a Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 3 Nov 2026 13:56:22 -0800 Subject: [PATCH 17/19] Remove weak return value test --- Lib/test/test_pyrepl/test_interact.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index a09294ccecf21bc..865174fcca7a6aa 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -489,13 +489,6 @@ def test_hook_system_exit_exits_repl(self): self.assertEqual(caught.exception.code, 42) hook.assert_called_once_with(statement) - def test_hook_return_value_is_ignored(self): - hook = MagicMock(return_value=True) - - _, namespace = self._run_interactive(["x = 1"], hook) - - self.assertEqual(namespace["x"], 1) - def test_hook_not_called_for_repl_commands(self): hook = MagicMock() self._run_interactive(["clear"], hook) From e28071a0a0aa97f909a219daa94acfa3c4532d8d Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 3 Nov 2026 14:11:57 -0800 Subject: [PATCH 18/19] Add PTY test --- Lib/test/test_pyrepl/test_pyrepl.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 1828fd735669d54..3575da825d4e1a2 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -2102,6 +2102,31 @@ def setUp(self): self.addCleanup(patcher.stop) patcher.start() + def test_statement_submitted_hook_exception(self): + env = make_clean_env() + env["NO_COLOR"] = "1" + output, exit_code = self.run_repl( + [ + "from _pyrepl import simple_interact as si", + "def hook(statement): raise RuntimeError('hook failed')", + "", + "si.statement_submitted_hook = hook", + "x = 1", + "print('executed:', x)", + "exit", + ], + env=env, + skip=True, + ) + + output = output.replace("\r\n", "\n") + self.assertEqual(exit_code, 0) + self.assertEqual( + output.count("Error in statement_submitted_hook:\n"), 2 + ) + self.assertEqual(output.count("RuntimeError: hook failed"), 2) + self.assertIn("\nexecuted: 1\n", output) + @force_not_colorized def test_exposed_globals_in_repl(self): pre = "['__builtins__'" From d6cd84868d4d70dc64f19e8d82517919842ad738 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 3 Nov 2026 14:15:55 -0800 Subject: [PATCH 19/19] Update tests --- Lib/_pyrepl/simple_interact.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index 45787d4f49ba6c7..f8ca67c54f4ca22 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -34,9 +34,6 @@ # Called with submitted input before execution, except for REPL commands. -# Final auto-indentation is removed; the return value is ignored. -# Ordinary exceptions are labelled and displayed without changing sys.last_* or -# stopping execution. KeyboardInterrupt cancels submission; SystemExit exits. statement_submitted_hook = None