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/_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..d5ba6a30ae97938 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,70 @@ 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 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 + + 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, + ) + ) + + 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(), 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, + ) + 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() + self._run_interactive(["clear"], pre_execution_hook=hook) + hook.assert_not_called() 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..c73e9e45c01454c --- /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.