From 4c18d3bdc20fa2cebe82e74bd025917196f5aad4 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 9 Sep 2026 14:34:37 -0700 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=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 5/5] 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.