Skip to content

Commit e6eb8ca

Browse files
GH-102895 Add an option local_exit in code.interact to block exit() from terminating the whole process (GH-102896)
1 parent cb1bf89 commit e6eb8ca

File tree

5 files changed

+114
-33
lines changed

5 files changed

+114
-33
lines changed

Doc/library/code.rst

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,27 +23,34 @@ build applications which provide an interactive interpreter prompt.
2323
``'__doc__'`` set to ``None``.
2424

2525

26-
.. class:: InteractiveConsole(locals=None, filename="<console>")
26+
.. class:: InteractiveConsole(locals=None, filename="<console>", local_exit=False)
2727

2828
Closely emulate the behavior of the interactive Python interpreter. This class
2929
builds on :class:`InteractiveInterpreter` and adds prompting using the familiar
30-
``sys.ps1`` and ``sys.ps2``, and input buffering.
30+
``sys.ps1`` and ``sys.ps2``, and input buffering. If *local_exit* is True,
31+
``exit()`` and ``quit()`` in the console will not raise :exc:`SystemExit`, but
32+
instead return to the calling code.
3133

34+
.. versionchanged:: 3.13
35+
Added *local_exit* parameter.
3236

33-
.. function:: interact(banner=None, readfunc=None, local=None, exitmsg=None)
37+
.. function:: interact(banner=None, readfunc=None, local=None, exitmsg=None, local_exit=False)
3438

3539
Convenience function to run a read-eval-print loop. This creates a new
3640
instance of :class:`InteractiveConsole` and sets *readfunc* to be used as
3741
the :meth:`InteractiveConsole.raw_input` method, if provided. If *local* is
3842
provided, it is passed to the :class:`InteractiveConsole` constructor for
39-
use as the default namespace for the interpreter loop. The :meth:`interact`
43+
use as the default namespace for the interpreter loop. If *local_exit* is provided,
44+
it is passed to the :class:`InteractiveConsole` constructor. The :meth:`interact`
4045
method of the instance is then run with *banner* and *exitmsg* passed as the
4146
banner and exit message to use, if provided. The console object is discarded
4247
after use.
4348

4449
.. versionchanged:: 3.6
4550
Added *exitmsg* parameter.
4651

52+
.. versionchanged:: 3.13
53+
Added *local_exit* parameter.
4754

4855
.. function:: compile_command(source, filename="<input>", symbol="single")
4956

Lib/code.py

Lines changed: 77 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# Inspired by similar code by Jeff Epler and Fredrik Lundh.
66

77

8+
import builtins
89
import sys
910
import traceback
1011
from codeop import CommandCompiler, compile_command
@@ -169,7 +170,7 @@ class InteractiveConsole(InteractiveInterpreter):
169170
170171
"""
171172

172-
def __init__(self, locals=None, filename="<console>"):
173+
def __init__(self, locals=None, filename="<console>", local_exit=False):
173174
"""Constructor.
174175
175176
The optional locals argument will be passed to the
@@ -181,6 +182,7 @@ def __init__(self, locals=None, filename="<console>"):
181182
"""
182183
InteractiveInterpreter.__init__(self, locals)
183184
self.filename = filename
185+
self.local_exit = local_exit
184186
self.resetbuffer()
185187

186188
def resetbuffer(self):
@@ -219,27 +221,64 @@ def interact(self, banner=None, exitmsg=None):
219221
elif banner:
220222
self.write("%s\n" % str(banner))
221223
more = 0
222-
while 1:
223-
try:
224-
if more:
225-
prompt = sys.ps2
226-
else:
227-
prompt = sys.ps1
224+
225+
# When the user uses exit() or quit() in their interactive shell
226+
# they probably just want to exit the created shell, not the whole
227+
# process. exit and quit in builtins closes sys.stdin which makes
228+
# it super difficult to restore
229+
#
230+
# When self.local_exit is True, we overwrite the builtins so
231+
# exit() and quit() only raises SystemExit and we can catch that
232+
# to only exit the interactive shell
233+
234+
_exit = None
235+
_quit = None
236+
237+
if self.local_exit:
238+
if hasattr(builtins, "exit"):
239+
_exit = builtins.exit
240+
builtins.exit = Quitter("exit")
241+
242+
if hasattr(builtins, "quit"):
243+
_quit = builtins.quit
244+
builtins.quit = Quitter("quit")
245+
246+
try:
247+
while True:
228248
try:
229-
line = self.raw_input(prompt)
230-
except EOFError:
231-
self.write("\n")
232-
break
233-
else:
234-
more = self.push(line)
235-
except KeyboardInterrupt:
236-
self.write("\nKeyboardInterrupt\n")
237-
self.resetbuffer()
238-
more = 0
239-
if exitmsg is None:
240-
self.write('now exiting %s...\n' % self.__class__.__name__)
241-
elif exitmsg != '':
242-
self.write('%s\n' % exitmsg)
249+
if more:
250+
prompt = sys.ps2
251+
else:
252+
prompt = sys.ps1
253+
try:
254+
line = self.raw_input(prompt)
255+
except EOFError:
256+
self.write("\n")
257+
break
258+
else:
259+
more = self.push(line)
260+
except KeyboardInterrupt:
261+
self.write("\nKeyboardInterrupt\n")
262+
self.resetbuffer()
263+
more = 0
264+
except SystemExit as e:
265+
if self.local_exit:
266+
self.write("\n")
267+
break
268+
else:
269+
raise e
270+
finally:
271+
# restore exit and quit in builtins if they were modified
272+
if _exit is not None:
273+
builtins.exit = _exit
274+
275+
if _quit is not None:
276+
builtins.quit = _quit
277+
278+
if exitmsg is None:
279+
self.write('now exiting %s...\n' % self.__class__.__name__)
280+
elif exitmsg != '':
281+
self.write('%s\n' % exitmsg)
243282

244283
def push(self, line):
245284
"""Push a line to the interpreter.
@@ -276,8 +315,22 @@ def raw_input(self, prompt=""):
276315
return input(prompt)
277316

278317

318+
class Quitter:
319+
def __init__(self, name):
320+
self.name = name
321+
if sys.platform == "win32":
322+
self.eof = 'Ctrl-Z plus Return'
323+
else:
324+
self.eof = 'Ctrl-D (i.e. EOF)'
325+
326+
def __repr__(self):
327+
return f'Use {self.name} or {self.eof} to exit'
328+
329+
def __call__(self, code=None):
330+
raise SystemExit(code)
331+
279332

280-
def interact(banner=None, readfunc=None, local=None, exitmsg=None):
333+
def interact(banner=None, readfunc=None, local=None, exitmsg=None, local_exit=False):
281334
"""Closely emulate the interactive Python interpreter.
282335
283336
This is a backwards compatible interface to the InteractiveConsole
@@ -290,9 +343,10 @@ def interact(banner=None, readfunc=None, local=None, exitmsg=None):
290343
readfunc -- if not None, replaces InteractiveConsole.raw_input()
291344
local -- passed to InteractiveInterpreter.__init__()
292345
exitmsg -- passed to InteractiveConsole.interact()
346+
local_exit -- passed to InteractiveConsole.__init__()
293347
294348
"""
295-
console = InteractiveConsole(local)
349+
console = InteractiveConsole(local, local_exit=local_exit)
296350
if readfunc is not None:
297351
console.raw_input = readfunc
298352
else:

Lib/pdb.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1741,7 +1741,7 @@ def do_interact(self, arg):
17411741
contains all the (global and local) names found in the current scope.
17421742
"""
17431743
ns = {**self.curframe.f_globals, **self.curframe_locals}
1744-
code.interact("*interactive*", local=ns)
1744+
code.interact("*interactive*", local=ns, local_exit=True)
17451745

17461746
def do_alias(self, arg):
17471747
"""alias [name [command]]

Lib/test/test_code_module.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,7 @@
1010
code = import_helper.import_module('code')
1111

1212

13-
class TestInteractiveConsole(unittest.TestCase):
14-
15-
def setUp(self):
16-
self.console = code.InteractiveConsole()
17-
self.mock_sys()
13+
class MockSys:
1814

1915
def mock_sys(self):
2016
"Mock system environment for InteractiveConsole"
@@ -32,6 +28,13 @@ def mock_sys(self):
3228
del self.sysmod.ps1
3329
del self.sysmod.ps2
3430

31+
32+
class TestInteractiveConsole(unittest.TestCase, MockSys):
33+
34+
def setUp(self):
35+
self.console = code.InteractiveConsole()
36+
self.mock_sys()
37+
3538
def test_ps1(self):
3639
self.infunc.side_effect = EOFError('Finished')
3740
self.console.interact()
@@ -151,5 +154,21 @@ def test_context_tb(self):
151154
self.assertIn(expected, output)
152155

153156

157+
class TestInteractiveConsoleLocalExit(unittest.TestCase, MockSys):
158+
159+
def setUp(self):
160+
self.console = code.InteractiveConsole(local_exit=True)
161+
self.mock_sys()
162+
163+
def test_exit(self):
164+
# default exit message
165+
self.infunc.side_effect = ["exit()"]
166+
self.console.interact(banner='')
167+
self.assertEqual(len(self.stderr.method_calls), 2)
168+
err_msg = self.stderr.method_calls[1]
169+
expected = 'now exiting InteractiveConsole...\n'
170+
self.assertEqual(err_msg, ['write', (expected,), {}])
171+
172+
154173
if __name__ == "__main__":
155174
unittest.main()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added a parameter ``local_exit`` for :func:`code.interact` to prevent ``exit()`` and ``quit`` from closing ``sys.stdin`` and raise ``SystemExit``.

0 commit comments

Comments
 (0)