diff --git a/Doc/library/code.rst b/Doc/library/code.rst index 59c016d21501b0..1e7127ee957b63 100644 --- a/Doc/library/code.rst +++ b/Doc/library/code.rst @@ -40,7 +40,7 @@ build applications which provide an interactive interpreter prompt. .. versionchanged:: 3.13 Added *local_exit* parameter. -.. function:: interact(banner=None, readfunc=None, local=None, exitmsg=None, local_exit=False) +.. function:: interact(banner=None, readfunc=None, local=None, exitmsg=None, local_exit=False, *, use_pyrepl=False) Convenience function to run a read-eval-print loop. This creates a new instance of :class:`InteractiveConsole` and sets *readfunc* to be used as @@ -49,8 +49,9 @@ build applications which provide an interactive interpreter prompt. use as the default namespace for the interpreter loop. If *local_exit* is provided, it is passed to the :class:`InteractiveConsole` constructor. The :meth:`~InteractiveConsole.interact` method of the instance is then run with *banner* and *exitmsg* passed as the - banner and exit message to use, if provided. The console object is discarded - after use. + banner and exit message to use, if provided. The *use_pyrepl* argument is + passed to :meth:`~InteractiveConsole.interact` to control whether to use the + pyrepl-based REPL. The console object is discarded after use. .. versionchanged:: 3.6 Added *exitmsg* parameter. @@ -58,6 +59,9 @@ build applications which provide an interactive interpreter prompt. .. versionchanged:: 3.13 Added *local_exit* parameter. + .. versionchanged:: next + Added *use_pyrepl* parameter. + .. function:: compile_command(source, filename="", symbol="single") This function is useful for programs that want to emulate Python's interpreter @@ -153,7 +157,7 @@ The :class:`InteractiveConsole` class is a subclass of interpreter objects as well as the following additions. -.. method:: InteractiveConsole.interact(banner=None, exitmsg=None) +.. method:: InteractiveConsole.interact(banner=None, exitmsg=None, *, use_pyrepl=False) Closely emulate the interactive Python console. The optional *banner* argument specify the banner to print before the first interaction; by default it prints a @@ -165,12 +169,21 @@ interpreter objects as well as the following additions. Pass the empty string to suppress the exit message. If *exitmsg* is not given or ``None``, a default message is printed. + The optional *use_pyrepl* argument controls whether to use the pyrepl-based REPL + when available. When ``True``, pyrepl is used. When ``False`` (the default), + the basic readline-based REPL is used. When ``None``, pyrepl is used + automatically if available and the :envvar:`PYTHON_BASIC_REPL` environment + variable is not set. + .. versionchanged:: 3.4 To suppress printing any banner, pass an empty string. .. versionchanged:: 3.6 Print an exit message when exiting. + .. versionchanged:: next + Added *use_pyrepl* parameter. + .. method:: InteractiveConsole.push(line) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 6543da4487e42f..757641f8106436 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -722,6 +722,18 @@ calendar (Contributed by Pål Grønås Drange in :gh:`140212`.) +code +---- + +* The :meth:`code.InteractiveConsole.interact` method and the :func:`code.interact` + function now support a *use_pyrepl* parameter to control whether to use the + pyrepl-based REPL. By default, *use_pyrepl* is ``False``, using the basic + readline-based REPL. When set to ``True``, pyrepl is used if available. + When set to ``None``, pyrepl is used automatically if available and the + :envvar:`PYTHON_BASIC_REPL` environment variable is not set. + (Contributed by Long Tan in :gh:`148261`.) + + collections ----------- @@ -830,9 +842,9 @@ http.server is colored by default. This can be controlled with :ref:`environment variables `. - (Contributed by Hugo van Kemenade in :gh:`146292`.) - - + (Contributed by Hugo van Kemenade in :gh:`146292`.) + + inspect ------- diff --git a/Lib/code.py b/Lib/code.py index f7e275d8801b7c..e842f212b50c1c 100644 --- a/Lib/code.py +++ b/Lib/code.py @@ -6,6 +6,7 @@ import builtins +import os import sys import traceback from codeop import CommandCompiler, compile_command @@ -201,7 +202,7 @@ def resetbuffer(self): """Reset the input buffer.""" self.buffer = [] - def interact(self, banner=None, exitmsg=None): + def interact(self, banner=None, exitmsg=None, *, use_pyrepl=False): """Closely emulate the interactive Python console. The optional banner argument specifies the banner to print @@ -216,7 +217,29 @@ def interact(self, banner=None, exitmsg=None): printing an exit message. If exitmsg is not given or None, a default message is printed. + The use_pyrepl argument controls whether to use the pyrepl-based REPL + when available. When True, pyrepl is used. When False (the default), + the basic readline-based REPL is used. When None, pyrepl is used + automatically if available and the PYTHON_BASIC_REPL environment + variable is not set. + """ + if use_pyrepl is None: + use_pyrepl = not os.getenv('PYTHON_BASIC_REPL') + + if use_pyrepl: + try: + from _pyrepl.main import CAN_USE_PYREPL + if CAN_USE_PYREPL: + from _pyrepl.simple_interact import ( + run_multiline_interactive_console, + ) + run_multiline_interactive_console(self) + return + except ImportError: + pass + # Fall through to basic REPL if pyrepl is unavailable + try: sys.ps1 delete_ps1_after = False @@ -355,7 +378,7 @@ def __call__(self, code=None): raise SystemExit(code) -def interact(banner=None, readfunc=None, local=None, exitmsg=None, local_exit=False): +def interact(banner=None, readfunc=None, local=None, exitmsg=None, local_exit=False, *, use_pyrepl=False): """Closely emulate the interactive Python interpreter. This is a backwards compatible interface to the InteractiveConsole @@ -369,6 +392,7 @@ def interact(banner=None, readfunc=None, local=None, exitmsg=None, local_exit=Fa local -- passed to InteractiveInterpreter.__init__() exitmsg -- passed to InteractiveConsole.interact() local_exit -- passed to InteractiveConsole.__init__() + use_pyrepl -- passed to InteractiveConsole.interact() """ console = InteractiveConsole(local, local_exit=local_exit) @@ -379,7 +403,7 @@ def interact(banner=None, readfunc=None, local=None, exitmsg=None, local_exit=Fa import readline # noqa: F401 except ImportError: pass - console.interact(banner, exitmsg) + console.interact(banner, exitmsg, use_pyrepl=use_pyrepl) if __name__ == "__main__": diff --git a/Lib/test/test_code_module.py b/Lib/test/test_code_module.py index 3642b47c2c1f03..1d8fe59ff4d028 100644 --- a/Lib/test/test_code_module.py +++ b/Lib/test/test_code_module.py @@ -1,4 +1,5 @@ "Test InteractiveConsole and InteractiveInterpreter from code module" +import os import sys import traceback import unittest @@ -44,7 +45,7 @@ def test_ps1(self): "code.sys.ps1", EOFError('Finished') ] - self.console.interact() + self.console.interact(use_pyrepl=False) output = ''.join(''.join(call[1]) for call in self.stdout.method_calls) self.assertIn('>>> ', output) self.assertNotHasAttr(self.sysmod, 'ps1') @@ -55,7 +56,7 @@ def test_ps1(self): EOFError('Finished') ] self.sysmod.ps1 = 'custom1> ' - self.console.interact() + self.console.interact(use_pyrepl=False) output = ''.join(''.join(call[1]) for call in self.stdout.method_calls) self.assertIn('custom1> ', output) self.assertEqual(self.sysmod.ps1, 'custom1> ') @@ -66,7 +67,7 @@ def test_ps2(self): "code.sys.ps2", EOFError('Finished') ] - self.console.interact() + self.console.interact(use_pyrepl=False) output = ''.join(''.join(call[1]) for call in self.stdout.method_calls) self.assertIn('... ', output) self.assertNotHasAttr(self.sysmod, 'ps2') @@ -77,14 +78,14 @@ def test_ps2(self): EOFError('Finished') ] self.sysmod.ps2 = 'custom2> ' - self.console.interact() + self.console.interact(use_pyrepl=False) output = ''.join(''.join(call[1]) for call in self.stdout.method_calls) self.assertIn('custom2> ', output) self.assertEqual(self.sysmod.ps2, 'custom2> ') def test_console_stderr(self): self.infunc.side_effect = ["'antioch'", "", EOFError('Finished')] - self.console.interact() + self.console.interact(use_pyrepl=False) for call in list(self.stdout.method_calls): if 'antioch' in ''.join(call[1]): break @@ -96,7 +97,7 @@ def test_syntax_error(self): " x = ?", "", EOFError('Finished')] - self.console.interact() + self.console.interact(use_pyrepl=False) output = ''.join(''.join(call[1]) for call in self.stderr.method_calls) output = output[output.index('(InteractiveConsole)'):] output = output[:output.index('\nnow exiting')] @@ -113,7 +114,7 @@ def test_syntax_error(self): def test_indentation_error(self): self.infunc.side_effect = [" 1", EOFError('Finished')] - self.console.interact() + self.console.interact(use_pyrepl=False) output = ''.join(''.join(call[1]) for call in self.stderr.method_calls) output = output[output.index('(InteractiveConsole)'):] output = output[:output.index('\nnow exiting')] @@ -129,7 +130,7 @@ def test_indentation_error(self): def test_unicode_error(self): self.infunc.side_effect = ["'\ud800'", EOFError('Finished')] - self.console.interact() + self.console.interact(use_pyrepl=False) output = ''.join(''.join(call[1]) for call in self.stderr.method_calls) output = output[output.index('(InteractiveConsole)'):] output = output[output.index('\n') + 1:] @@ -148,7 +149,7 @@ def test_sysexcepthook(self): EOFError('Finished')] hook = mock.Mock() self.sysmod.excepthook = hook - self.console.interact() + self.console.interact(use_pyrepl=False) hook.assert_called() hook.assert_called_with(self.sysmod.last_type, self.sysmod.last_value, @@ -170,7 +171,7 @@ def test_sysexcepthook_syntax_error(self): EOFError('Finished')] hook = mock.Mock() self.sysmod.excepthook = hook - self.console.interact() + self.console.interact(use_pyrepl=False) hook.assert_called() hook.assert_called_with(self.sysmod.last_type, self.sysmod.last_value, @@ -190,7 +191,7 @@ def test_sysexcepthook_indentation_error(self): self.infunc.side_effect = [" 1", EOFError('Finished')] hook = mock.Mock() self.sysmod.excepthook = hook - self.console.interact() + self.console.interact(use_pyrepl=False) hook.assert_called() hook.assert_called_with(self.sysmod.last_type, self.sysmod.last_value, @@ -208,7 +209,7 @@ def test_sysexcepthook_indentation_error(self): def test_sysexcepthook_crashing_doesnt_close_repl(self): self.infunc.side_effect = ["1/0", "a = 123", "print(a)", EOFError('Finished')] self.sysmod.excepthook = 1 - self.console.interact() + self.console.interact(use_pyrepl=False) self.assertEqual(['write', ('123', ), {}], self.stdout.method_calls[0]) error = "".join(call.args[0] for call in self.stderr.method_calls if call[0] == 'write') self.assertIn("Error in sys.excepthook:", error) @@ -222,7 +223,7 @@ def test_sysexcepthook_raising_BaseException(self): def raise_base(*args, **kwargs): raise BaseException(s) self.sysmod.excepthook = raise_base - self.console.interact() + self.console.interact(use_pyrepl=False) self.assertEqual(['write', ('123', ), {}], self.stdout.method_calls[0]) error = "".join(call.args[0] for call in self.stderr.method_calls if call[0] == 'write') self.assertIn("Error in sys.excepthook:", error) @@ -236,12 +237,12 @@ def raise_base(*args, **kwargs): raise SystemExit self.sysmod.excepthook = raise_base with self.assertRaises(SystemExit): - self.console.interact() + self.console.interact(use_pyrepl=False) def test_banner(self): # with banner self.infunc.side_effect = EOFError('Finished') - self.console.interact(banner='Foo') + self.console.interact(banner='Foo', use_pyrepl=False) self.assertEqual(len(self.stderr.method_calls), 3) banner_call = self.stderr.method_calls[0] self.assertEqual(banner_call, ['write', ('Foo\n',), {}]) @@ -249,13 +250,13 @@ def test_banner(self): # no banner self.stderr.reset_mock() self.infunc.side_effect = EOFError('Finished') - self.console.interact(banner='') + self.console.interact(banner='', use_pyrepl=False) self.assertEqual(len(self.stderr.method_calls), 2) def test_exit_msg(self): # default exit message self.infunc.side_effect = EOFError('Finished') - self.console.interact(banner='') + self.console.interact(banner='', use_pyrepl=False) self.assertEqual(len(self.stderr.method_calls), 2) err_msg = self.stderr.method_calls[1] expected = 'now exiting InteractiveConsole...\n' @@ -264,7 +265,7 @@ def test_exit_msg(self): # no exit message self.stderr.reset_mock() self.infunc.side_effect = EOFError('Finished') - self.console.interact(banner='', exitmsg='') + self.console.interact(banner='', exitmsg='', use_pyrepl=False) self.assertEqual(len(self.stderr.method_calls), 1) # custom exit message @@ -273,7 +274,7 @@ def test_exit_msg(self): 'bye! \N{GREEK SMALL LETTER ZETA}\N{CYRILLIC SMALL LETTER ZHE}' ) self.infunc.side_effect = EOFError('Finished') - self.console.interact(banner='', exitmsg=message) + self.console.interact(banner='', exitmsg=message, use_pyrepl=False) self.assertEqual(len(self.stderr.method_calls), 2) err_msg = self.stderr.method_calls[1] expected = message + '\n' @@ -283,7 +284,7 @@ def test_exit_msg(self): def test_cause_tb(self): self.infunc.side_effect = ["raise ValueError('') from AttributeError", EOFError('Finished')] - self.console.interact() + self.console.interact(use_pyrepl=False) output = ''.join(''.join(call[1]) for call in self.stderr.method_calls) expected = dedent(""" AttributeError @@ -304,7 +305,7 @@ def test_cause_tb(self): def test_context_tb(self): self.infunc.side_effect = ["try: ham\nexcept: eggs\n", EOFError('Finished')] - self.console.interact() + self.console.interact(use_pyrepl=False) output = ''.join(''.join(call[1]) for call in self.stderr.method_calls) expected = dedent(""" Traceback (most recent call last): @@ -335,12 +336,60 @@ def setUp(self): def test_exit(self): # default exit message self.infunc.side_effect = ["exit()"] - self.console.interact(banner='') + self.console.interact(banner='', use_pyrepl=False) self.assertEqual(len(self.stderr.method_calls), 2) err_msg = self.stderr.method_calls[1] expected = 'now exiting InteractiveConsole...\n' self.assertEqual(err_msg, ['write', (expected,), {}]) +class TestInteractiveConsoleUsePyrepl(unittest.TestCase, MockSys): + """Tests for the use_pyrepl parameter of InteractiveConsole.interact().""" + + def setUp(self): + self.console = code.InteractiveConsole() + self.mock_sys() + + def test_use_pyrepl_false_uses_basic_repl(self): + """When use_pyrepl=False, the basic REPL should be used.""" + self.infunc.side_effect = ["'test'", EOFError('Finished')] + self.console.interact(banner='', use_pyrepl=False) + # Should have used code.input (the basic REPL) + self.infunc.assert_called() + + @mock.patch.object(os, 'getenv', return_value='1') + def test_python_basic_repl_env_uses_basic_repl(self, mock_getenv): + """When PYTHON_BASIC_REPL is set, the basic REPL should be used.""" + self.infunc.side_effect = ["'test'", EOFError('Finished')] + self.console.interact(banner='') + # Should have used code.input (the basic REPL) + self.infunc.assert_called() + + def test_use_pyrepl_false_with_input(self): + """Test that use_pyrepl=False correctly processes input.""" + self.infunc.side_effect = [ + "x = 1", + "x", + EOFError('Finished') + ] + self.console.interact(banner='', use_pyrepl=False) + output = ''.join(''.join(call[1]) for call in self.stdout.method_calls) + self.assertIn('1', output) + + +class TestInteractFunctionUsePyrepl(unittest.TestCase, MockSys): + """Tests for the use_pyrepl parameter of the top-level interact() function.""" + + def setUp(self): + self.mock_sys() + + def test_interact_use_pyrepl_false(self): + """When use_pyrepl=False, the basic REPL should be used.""" + self.infunc.side_effect = ["'test'", EOFError('Finished')] + with mock.patch('code.input', create=True, side_effect=self.infunc): + code.interact(banner='', use_pyrepl=False) + self.infunc.assert_called() + + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Library/2026-04-09-00-38-52.gh-issue-119512.Z6V-kp.rst b/Misc/NEWS.d/next/Library/2026-04-09-00-38-52.gh-issue-119512.Z6V-kp.rst new file mode 100644 index 00000000000000..d666f710a4328c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-04-09-00-38-52.gh-issue-119512.Z6V-kp.rst @@ -0,0 +1 @@ +Use new REPL for the :mod:`code` module.