GH-102895 Add an option local_exit in code.interact to block exit() from terminating the whole process (GH-102896)

This commit is contained in:
Tian Gao 2023-10-18 11:36:43 -07:00 committed by GitHub
parent cb1bf89c40
commit e6eb8cafca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 114 additions and 33 deletions

View File

@ -23,20 +23,25 @@ build applications which provide an interactive interpreter prompt.
``'__doc__'`` set to ``None``. ``'__doc__'`` set to ``None``.
.. class:: InteractiveConsole(locals=None, filename="<console>") .. class:: InteractiveConsole(locals=None, filename="<console>", local_exit=False)
Closely emulate the behavior of the interactive Python interpreter. This class Closely emulate the behavior of the interactive Python interpreter. This class
builds on :class:`InteractiveInterpreter` and adds prompting using the familiar builds on :class:`InteractiveInterpreter` and adds prompting using the familiar
``sys.ps1`` and ``sys.ps2``, and input buffering. ``sys.ps1`` and ``sys.ps2``, and input buffering. If *local_exit* is True,
``exit()`` and ``quit()`` in the console will not raise :exc:`SystemExit`, but
instead return to the calling code.
.. versionchanged:: 3.13
Added *local_exit* parameter.
.. function:: interact(banner=None, readfunc=None, local=None, exitmsg=None) .. function:: interact(banner=None, readfunc=None, local=None, exitmsg=None, local_exit=False)
Convenience function to run a read-eval-print loop. This creates a new Convenience function to run a read-eval-print loop. This creates a new
instance of :class:`InteractiveConsole` and sets *readfunc* to be used as instance of :class:`InteractiveConsole` and sets *readfunc* to be used as
the :meth:`InteractiveConsole.raw_input` method, if provided. If *local* is the :meth:`InteractiveConsole.raw_input` method, if provided. If *local* is
provided, it is passed to the :class:`InteractiveConsole` constructor for provided, it is passed to the :class:`InteractiveConsole` constructor for
use as the default namespace for the interpreter loop. The :meth:`interact` use as the default namespace for the interpreter loop. If *local_exit* is provided,
it is passed to the :class:`InteractiveConsole` constructor. The :meth:`interact`
method of the instance is then run with *banner* and *exitmsg* passed as the 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 banner and exit message to use, if provided. The console object is discarded
after use. after use.
@ -44,6 +49,8 @@ build applications which provide an interactive interpreter prompt.
.. versionchanged:: 3.6 .. versionchanged:: 3.6
Added *exitmsg* parameter. Added *exitmsg* parameter.
.. versionchanged:: 3.13
Added *local_exit* parameter.
.. function:: compile_command(source, filename="<input>", symbol="single") .. function:: compile_command(source, filename="<input>", symbol="single")

View File

@ -5,6 +5,7 @@
# Inspired by similar code by Jeff Epler and Fredrik Lundh. # Inspired by similar code by Jeff Epler and Fredrik Lundh.
import builtins
import sys import sys
import traceback import traceback
from codeop import CommandCompiler, compile_command from codeop import CommandCompiler, compile_command
@ -169,7 +170,7 @@ class InteractiveConsole(InteractiveInterpreter):
""" """
def __init__(self, locals=None, filename="<console>"): def __init__(self, locals=None, filename="<console>", local_exit=False):
"""Constructor. """Constructor.
The optional locals argument will be passed to the The optional locals argument will be passed to the
@ -181,6 +182,7 @@ def __init__(self, locals=None, filename="<console>"):
""" """
InteractiveInterpreter.__init__(self, locals) InteractiveInterpreter.__init__(self, locals)
self.filename = filename self.filename = filename
self.local_exit = local_exit
self.resetbuffer() self.resetbuffer()
def resetbuffer(self): def resetbuffer(self):
@ -219,7 +221,30 @@ def interact(self, banner=None, exitmsg=None):
elif banner: elif banner:
self.write("%s\n" % str(banner)) self.write("%s\n" % str(banner))
more = 0 more = 0
while 1:
# When the user uses exit() or quit() in their interactive shell
# they probably just want to exit the created shell, not the whole
# process. exit and quit in builtins closes sys.stdin which makes
# it super difficult to restore
#
# When self.local_exit is True, we overwrite the builtins so
# exit() and quit() only raises SystemExit and we can catch that
# to only exit the interactive shell
_exit = None
_quit = None
if self.local_exit:
if hasattr(builtins, "exit"):
_exit = builtins.exit
builtins.exit = Quitter("exit")
if hasattr(builtins, "quit"):
_quit = builtins.quit
builtins.quit = Quitter("quit")
try:
while True:
try: try:
if more: if more:
prompt = sys.ps2 prompt = sys.ps2
@ -236,6 +261,20 @@ def interact(self, banner=None, exitmsg=None):
self.write("\nKeyboardInterrupt\n") self.write("\nKeyboardInterrupt\n")
self.resetbuffer() self.resetbuffer()
more = 0 more = 0
except SystemExit as e:
if self.local_exit:
self.write("\n")
break
else:
raise e
finally:
# restore exit and quit in builtins if they were modified
if _exit is not None:
builtins.exit = _exit
if _quit is not None:
builtins.quit = _quit
if exitmsg is None: if exitmsg is None:
self.write('now exiting %s...\n' % self.__class__.__name__) self.write('now exiting %s...\n' % self.__class__.__name__)
elif exitmsg != '': elif exitmsg != '':
@ -276,8 +315,22 @@ def raw_input(self, prompt=""):
return input(prompt) return input(prompt)
class Quitter:
def __init__(self, name):
self.name = name
if sys.platform == "win32":
self.eof = 'Ctrl-Z plus Return'
else:
self.eof = 'Ctrl-D (i.e. EOF)'
def interact(banner=None, readfunc=None, local=None, exitmsg=None): def __repr__(self):
return f'Use {self.name} or {self.eof} to exit'
def __call__(self, code=None):
raise SystemExit(code)
def interact(banner=None, readfunc=None, local=None, exitmsg=None, local_exit=False):
"""Closely emulate the interactive Python interpreter. """Closely emulate the interactive Python interpreter.
This is a backwards compatible interface to the InteractiveConsole This is a backwards compatible interface to the InteractiveConsole
@ -290,9 +343,10 @@ def interact(banner=None, readfunc=None, local=None, exitmsg=None):
readfunc -- if not None, replaces InteractiveConsole.raw_input() readfunc -- if not None, replaces InteractiveConsole.raw_input()
local -- passed to InteractiveInterpreter.__init__() local -- passed to InteractiveInterpreter.__init__()
exitmsg -- passed to InteractiveConsole.interact() exitmsg -- passed to InteractiveConsole.interact()
local_exit -- passed to InteractiveConsole.__init__()
""" """
console = InteractiveConsole(local) console = InteractiveConsole(local, local_exit=local_exit)
if readfunc is not None: if readfunc is not None:
console.raw_input = readfunc console.raw_input = readfunc
else: else:

View File

@ -1741,7 +1741,7 @@ def do_interact(self, arg):
contains all the (global and local) names found in the current scope. contains all the (global and local) names found in the current scope.
""" """
ns = {**self.curframe.f_globals, **self.curframe_locals} ns = {**self.curframe.f_globals, **self.curframe_locals}
code.interact("*interactive*", local=ns) code.interact("*interactive*", local=ns, local_exit=True)
def do_alias(self, arg): def do_alias(self, arg):
"""alias [name [command]] """alias [name [command]]

View File

@ -10,11 +10,7 @@
code = import_helper.import_module('code') code = import_helper.import_module('code')
class TestInteractiveConsole(unittest.TestCase): class MockSys:
def setUp(self):
self.console = code.InteractiveConsole()
self.mock_sys()
def mock_sys(self): def mock_sys(self):
"Mock system environment for InteractiveConsole" "Mock system environment for InteractiveConsole"
@ -32,6 +28,13 @@ def mock_sys(self):
del self.sysmod.ps1 del self.sysmod.ps1
del self.sysmod.ps2 del self.sysmod.ps2
class TestInteractiveConsole(unittest.TestCase, MockSys):
def setUp(self):
self.console = code.InteractiveConsole()
self.mock_sys()
def test_ps1(self): def test_ps1(self):
self.infunc.side_effect = EOFError('Finished') self.infunc.side_effect = EOFError('Finished')
self.console.interact() self.console.interact()
@ -151,5 +154,21 @@ def test_context_tb(self):
self.assertIn(expected, output) self.assertIn(expected, output)
class TestInteractiveConsoleLocalExit(unittest.TestCase, MockSys):
def setUp(self):
self.console = code.InteractiveConsole(local_exit=True)
self.mock_sys()
def test_exit(self):
# default exit message
self.infunc.side_effect = ["exit()"]
self.console.interact(banner='')
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,), {}])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -0,0 +1 @@
Added a parameter ``local_exit`` for :func:`code.interact` to prevent ``exit()`` and ``quit`` from closing ``sys.stdin`` and raise ``SystemExit``.