gh-79871: IDLE - Fix and test debugger module (#11451)

Add docstrings to the debugger module. Fix two bugs: initialize Idb.botframe (should be in Bdb); In Idb.in_rpc_code, check whether prev_frame is None before trying to use it. Make other code changes.

Expand test_debugger coverage from 19% to 66%.
---------
Co-authored-by: Terry Jan Reedy <tjreedy@udel.edu>
This commit is contained in:
Anthony Shaw 2023-11-19 15:20:38 +11:00 committed by GitHub
parent 18c6929469
commit adedcfa06b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 397 additions and 74 deletions

View file

@ -1,3 +1,20 @@
"""Debug user code with a GUI interface to a subclass of bdb.Bdb.
The Idb idb and Debugger gui instances each need a reference to each
other or to an rpc proxy for each other.
If IDLE is started with '-n', so that user code and idb both run in the
IDLE process, Debugger is called without an idb. Debugger.__init__
calls Idb with its incomplete self. Idb.__init__ stores gui and gui
then stores idb.
If IDLE is started normally, so that user code executes in a separate
process, debugger_r.start_remote_debugger is called, executing in the
IDLE process. It calls 'start the debugger' in the remote process,
which calls Idb with a gui proxy. Then Debugger is called in the IDLE
for more.
"""
import bdb
import os
@ -10,66 +27,95 @@
class Idb(bdb.Bdb):
"Supply user_line and user_exception functions for Bdb."
def __init__(self, gui):
self.gui = gui # An instance of Debugger or proxy of remote.
bdb.Bdb.__init__(self)
self.gui = gui # An instance of Debugger or proxy thereof.
super().__init__()
def user_line(self, frame):
if self.in_rpc_code(frame):
"""Handle a user stopping or breaking at a line.
Convert frame to a string and send it to gui.
"""
if _in_rpc_code(frame):
self.set_step()
return
message = self.__frame2message(frame)
message = _frame2message(frame)
try:
self.gui.interaction(message, frame)
except TclError: # When closing debugger window with [x] in 3.x
pass
def user_exception(self, frame, info):
if self.in_rpc_code(frame):
def user_exception(self, frame, exc_info):
"""Handle an the occurrence of an exception."""
if _in_rpc_code(frame):
self.set_step()
return
message = self.__frame2message(frame)
self.gui.interaction(message, frame, info)
message = _frame2message(frame)
self.gui.interaction(message, frame, exc_info)
def in_rpc_code(self, frame):
if frame.f_code.co_filename.count('rpc.py'):
return True
else:
prev_frame = frame.f_back
prev_name = prev_frame.f_code.co_filename
if 'idlelib' in prev_name and 'debugger' in prev_name:
# catch both idlelib/debugger.py and idlelib/debugger_r.py
# on both Posix and Windows
return False
return self.in_rpc_code(prev_frame)
def _in_rpc_code(frame):
"Determine if debugger is within RPC code."
if frame.f_code.co_filename.count('rpc.py'):
return True # Skip this frame.
else:
prev_frame = frame.f_back
if prev_frame is None:
return False
prev_name = prev_frame.f_code.co_filename
if 'idlelib' in prev_name and 'debugger' in prev_name:
# catch both idlelib/debugger.py and idlelib/debugger_r.py
# on both Posix and Windows
return False
return _in_rpc_code(prev_frame)
def __frame2message(self, frame):
code = frame.f_code
filename = code.co_filename
lineno = frame.f_lineno
basename = os.path.basename(filename)
message = f"{basename}:{lineno}"
if code.co_name != "?":
message = f"{message}: {code.co_name}()"
return message
def _frame2message(frame):
"""Return a message string for frame."""
code = frame.f_code
filename = code.co_filename
lineno = frame.f_lineno
basename = os.path.basename(filename)
message = f"{basename}:{lineno}"
if code.co_name != "?":
message = f"{message}: {code.co_name}()"
return message
class Debugger:
"""The debugger interface.
vstack = vsource = vlocals = vglobals = None
This class handles the drawing of the debugger window and
the interactions with the underlying debugger session.
"""
vstack = None
vsource = None
vlocals = None
vglobals = None
stackviewer = None
localsviewer = None
globalsviewer = None
def __init__(self, pyshell, idb=None):
"""Instantiate and draw a debugger window.
:param pyshell: An instance of the PyShell Window
:type pyshell: :class:`idlelib.pyshell.PyShell`
:param idb: An instance of the IDLE debugger (optional)
:type idb: :class:`idlelib.debugger.Idb`
"""
if idb is None:
idb = Idb(self)
self.pyshell = pyshell
self.idb = idb # If passed, a proxy of remote instance.
self.frame = None
self.make_gui()
self.interacting = 0
self.interacting = False
self.nesting_level = 0
def run(self, *args):
"""Run the debugger."""
# Deal with the scenario where we've already got a program running
# in the debugger and we want to start another. If that is the case,
# our second 'run' was invoked from an event dispatched not from
@ -104,12 +150,13 @@ def run(self, *args):
self.root.after(100, lambda: self.run(*args))
return
try:
self.interacting = 1
self.interacting = True
return self.idb.run(*args)
finally:
self.interacting = 0
self.interacting = False
def close(self, event=None):
"""Close the debugger and window."""
try:
self.quit()
except Exception:
@ -127,6 +174,7 @@ def close(self, event=None):
self.top.destroy()
def make_gui(self):
"""Draw the debugger gui on the screen."""
pyshell = self.pyshell
self.flist = pyshell.flist
self.root = root = pyshell.root
@ -135,11 +183,11 @@ def make_gui(self):
self.top.wm_iconname("Debug")
top.wm_protocol("WM_DELETE_WINDOW", self.close)
self.top.bind("<Escape>", self.close)
#
self.bframe = bframe = Frame(top)
self.bframe.pack(anchor="w")
self.buttons = bl = []
#
self.bcont = b = Button(bframe, text="Go", command=self.cont)
bl.append(b)
self.bstep = b = Button(bframe, text="Step", command=self.step)
@ -150,14 +198,14 @@ def make_gui(self):
bl.append(b)
self.bret = b = Button(bframe, text="Quit", command=self.quit)
bl.append(b)
#
for b in bl:
b.configure(state="disabled")
b.pack(side="left")
#
self.cframe = cframe = Frame(bframe)
self.cframe.pack(side="left")
#
if not self.vstack:
self.__class__.vstack = BooleanVar(top)
self.vstack.set(1)
@ -180,20 +228,20 @@ def make_gui(self):
self.bglobals = Checkbutton(cframe,
text="Globals", command=self.show_globals, variable=self.vglobals)
self.bglobals.grid(row=1, column=1)
#
self.status = Label(top, anchor="w")
self.status.pack(anchor="w")
self.error = Label(top, anchor="w")
self.error.pack(anchor="w", fill="x")
self.errorbg = self.error.cget("background")
#
self.fstack = Frame(top, height=1)
self.fstack.pack(expand=1, fill="both")
self.flocals = Frame(top)
self.flocals.pack(expand=1, fill="both")
self.fglobals = Frame(top, height=1)
self.fglobals.pack(expand=1, fill="both")
#
if self.vstack.get():
self.show_stack()
if self.vlocals.get():
@ -204,7 +252,7 @@ def make_gui(self):
def interaction(self, message, frame, info=None):
self.frame = frame
self.status.configure(text=message)
#
if info:
type, value, tb = info
try:
@ -223,28 +271,28 @@ def interaction(self, message, frame, info=None):
tb = None
bg = self.errorbg
self.error.configure(text=m1, background=bg)
#
sv = self.stackviewer
if sv:
stack, i = self.idb.get_stack(self.frame, tb)
sv.load_stack(stack, i)
#
self.show_variables(1)
#
if self.vsource.get():
self.sync_source_line()
#
for b in self.buttons:
b.configure(state="normal")
#
self.top.wakeup()
# Nested main loop: Tkinter's main loop is not reentrant, so use
# Tcl's vwait facility, which reenters the event loop until an
# event handler sets the variable we're waiting on
# event handler sets the variable we're waiting on.
self.nesting_level += 1
self.root.tk.call('vwait', '::idledebugwait')
self.nesting_level -= 1
#
for b in self.buttons:
b.configure(state="disabled")
self.status.configure(text="")
@ -288,8 +336,6 @@ def quit(self):
def abort_loop(self):
self.root.tk.call('set', '::idledebugwait', '1')
stackviewer = None
def show_stack(self):
if not self.stackviewer and self.vstack.get():
self.stackviewer = sv = StackViewer(self.fstack, self.flist, self)
@ -311,9 +357,6 @@ def show_frame(self, stackitem):
self.frame = stackitem[0] # lineno is stackitem[1]
self.show_variables()
localsviewer = None
globalsviewer = None
def show_locals(self):
lv = self.localsviewer
if self.vlocals.get():
@ -354,26 +397,32 @@ def show_variables(self, force=0):
if gv:
gv.load_dict(gdict, force, self.pyshell.interp.rpcclt)
def set_breakpoint_here(self, filename, lineno):
def set_breakpoint(self, filename, lineno):
"""Set a filename-lineno breakpoint in the debugger.
Called from self.load_breakpoints and EW.setbreakpoint
"""
self.idb.set_break(filename, lineno)
def clear_breakpoint_here(self, filename, lineno):
def clear_breakpoint(self, filename, lineno):
self.idb.clear_break(filename, lineno)
def clear_file_breaks(self, filename):
self.idb.clear_all_file_breaks(filename)
def load_breakpoints(self):
"Load PyShellEditorWindow breakpoints into subprocess debugger"
"""Load PyShellEditorWindow breakpoints into subprocess debugger."""
for editwin in self.pyshell.flist.inversedict:
filename = editwin.io.filename
try:
for lineno in editwin.breakpoints:
self.set_breakpoint_here(filename, lineno)
self.set_breakpoint(filename, lineno)
except AttributeError:
continue
class StackViewer(ScrolledList):
"Code stack viewer for debugger GUI."
def __init__(self, master, flist, gui):
if macosx.isAquaTk():
@ -414,12 +463,12 @@ def load_stack(self, stack, index=None):
self.select(index)
def popup_event(self, event):
"override base method"
"Override base method."
if self.stack:
return ScrolledList.popup_event(self, event)
def fill_menu(self):
"override base method"
"Override base method."
menu = self.menu
menu.add_command(label="Go to source line",
command=self.goto_source_line)
@ -427,12 +476,12 @@ def fill_menu(self):
command=self.show_stack_frame)
def on_select(self, index):
"override base method"
"Override base method."
if 0 <= index < len(self.stack):
self.gui.show_frame(self.stack[index])
def on_double(self, index):
"override base method"
"Override base method."
self.show_source(index)
def goto_source_line(self):
@ -457,6 +506,7 @@ def show_source(self, index):
class NamespaceViewer:
"Global/local namespace viewer for debugger GUI."
def __init__(self, master, title, dict=None):
width = 0
@ -544,6 +594,7 @@ def load_dict(self, dict, force=0, rpc_client=None):
def close(self):
self.frame.destroy()
if __name__ == "__main__":
from unittest import main
main('idlelib.idle_test.test_debugger', verbosity=2, exit=False)

View file

@ -1,11 +1,279 @@
"Test debugger, coverage 19%"
from idlelib import debugger
import unittest
from test.support import requires
requires('gui')
from collections import namedtuple
from textwrap import dedent
from tkinter import Tk
from test.support import requires
import unittest
from unittest import mock
from unittest.mock import Mock, patch
"""A test python script for the debug tests."""
TEST_CODE = dedent("""
i = 1
i += 2
if i == 3:
print(i)
""")
class MockFrame:
"Minimal mock frame."
def __init__(self, code, lineno):
self.f_code = code
self.f_lineno = lineno
class IdbTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.gui = Mock()
cls.idb = debugger.Idb(cls.gui)
# Create test and code objects to simulate a debug session.
code_obj = compile(TEST_CODE, 'idlelib/file.py', mode='exec')
frame1 = MockFrame(code_obj, 1)
frame1.f_back = None
frame2 = MockFrame(code_obj, 2)
frame2.f_back = frame1
cls.frame = frame2
cls.msg = 'file.py:2: <module>()'
def test_init(self):
# Test that Idb.__init_ calls Bdb.__init__.
idb = debugger.Idb(None)
self.assertIsNone(idb.gui)
self.assertTrue(hasattr(idb, 'breaks'))
def test_user_line(self):
# Test that .user_line() creates a string message for a frame.
self.gui.interaction = Mock()
self.idb.user_line(self.frame)
self.gui.interaction.assert_called_once_with(self.msg, self.frame)
def test_user_exception(self):
# Test that .user_exception() creates a string message for a frame.
exc_info = (type(ValueError), ValueError(), None)
self.gui.interaction = Mock()
self.idb.user_exception(self.frame, exc_info)
self.gui.interaction.assert_called_once_with(
self.msg, self.frame, exc_info)
class FunctionTest(unittest.TestCase):
# Test module functions together.
def test_functions(self):
rpc_obj = compile(TEST_CODE,'rpc.py', mode='exec')
rpc_frame = MockFrame(rpc_obj, 2)
rpc_frame.f_back = rpc_frame
self.assertTrue(debugger._in_rpc_code(rpc_frame))
self.assertEqual(debugger._frame2message(rpc_frame),
'rpc.py:2: <module>()')
code_obj = compile(TEST_CODE, 'idlelib/debugger.py', mode='exec')
code_frame = MockFrame(code_obj, 1)
code_frame.f_back = None
self.assertFalse(debugger._in_rpc_code(code_frame))
self.assertEqual(debugger._frame2message(code_frame),
'debugger.py:1: <module>()')
code_frame.f_back = code_frame
self.assertFalse(debugger._in_rpc_code(code_frame))
code_frame.f_back = rpc_frame
self.assertTrue(debugger._in_rpc_code(code_frame))
class DebuggerTest(unittest.TestCase):
"Tests for Debugger that do not need a real root."
@classmethod
def setUpClass(cls):
cls.pyshell = Mock()
cls.pyshell.root = Mock()
cls.idb = Mock()
with patch.object(debugger.Debugger, 'make_gui'):
cls.debugger = debugger.Debugger(cls.pyshell, cls.idb)
cls.debugger.root = Mock()
def test_cont(self):
self.debugger.cont()
self.idb.set_continue.assert_called_once()
def test_step(self):
self.debugger.step()
self.idb.set_step.assert_called_once()
def test_quit(self):
self.debugger.quit()
self.idb.set_quit.assert_called_once()
def test_next(self):
with patch.object(self.debugger, 'frame') as frame:
self.debugger.next()
self.idb.set_next.assert_called_once_with(frame)
def test_ret(self):
with patch.object(self.debugger, 'frame') as frame:
self.debugger.ret()
self.idb.set_return.assert_called_once_with(frame)
def test_clear_breakpoint(self):
self.debugger.clear_breakpoint('test.py', 4)
self.idb.clear_break.assert_called_once_with('test.py', 4)
def test_clear_file_breaks(self):
self.debugger.clear_file_breaks('test.py')
self.idb.clear_all_file_breaks.assert_called_once_with('test.py')
def test_set_load_breakpoints(self):
# Test the .load_breakpoints() method calls idb.
FileIO = namedtuple('FileIO', 'filename')
class MockEditWindow(object):
def __init__(self, fn, breakpoints):
self.io = FileIO(fn)
self.breakpoints = breakpoints
self.pyshell.flist = Mock()
self.pyshell.flist.inversedict = (
MockEditWindow('test1.py', [4, 4]),
MockEditWindow('test2.py', [13, 44, 45]),
)
self.debugger.set_breakpoint('test0.py', 1)
self.idb.set_break.assert_called_once_with('test0.py', 1)
self.debugger.load_breakpoints() # Call set_breakpoint 5 times.
self.idb.set_break.assert_has_calls(
[mock.call('test0.py', 1),
mock.call('test1.py', 4),
mock.call('test1.py', 4),
mock.call('test2.py', 13),
mock.call('test2.py', 44),
mock.call('test2.py', 45)])
def test_sync_source_line(self):
# Test that .sync_source_line() will set the flist.gotofileline with fixed frame.
test_code = compile(TEST_CODE, 'test_sync.py', 'exec')
test_frame = MockFrame(test_code, 1)
self.debugger.frame = test_frame
self.debugger.flist = Mock()
with patch('idlelib.debugger.os.path.exists', return_value=True):
self.debugger.sync_source_line()
self.debugger.flist.gotofileline.assert_called_once_with('test_sync.py', 1)
class DebuggerGuiTest(unittest.TestCase):
"""Tests for debugger.Debugger that need tk root.
close needs debugger.top set in make_gui.
"""
@classmethod
def setUpClass(cls):
requires('gui')
cls.root = root = Tk()
root.withdraw()
cls.pyshell = Mock()
cls.pyshell.root = root
cls.idb = Mock()
# stack tests fail with debugger here.
## cls.debugger = debugger.Debugger(cls.pyshell, cls.idb)
## cls.debugger.root = root
## # real root needed for real make_gui
## # run, interacting, abort_loop
@classmethod
def tearDownClass(cls):
cls.root.destroy()
del cls.root
def setUp(self):
self.debugger = debugger.Debugger(self.pyshell, self.idb)
self.debugger.root = self.root
# real root needed for real make_gui
# run, interacting, abort_loop
def test_run_debugger(self):
self.debugger.run(1, 'two')
self.idb.run.assert_called_once_with(1, 'two')
self.assertEqual(self.debugger.interacting, 0)
def test_close(self):
# Test closing the window in an idle state.
self.debugger.close()
self.pyshell.close_debugger.assert_called_once()
def test_show_stack(self):
self.debugger.show_stack()
self.assertEqual(self.debugger.stackviewer.gui, self.debugger)
def test_show_stack_with_frame(self):
test_frame = MockFrame(None, None)
self.debugger.frame = test_frame
# Reset the stackviewer to force it to be recreated.
self.debugger.stackviewer = None
self.idb.get_stack.return_value = ([], 0)
self.debugger.show_stack()
# Check that the newly created stackviewer has the test gui as a field.
self.assertEqual(self.debugger.stackviewer.gui, self.debugger)
self.idb.get_stack.assert_called_once_with(test_frame, None)
class StackViewerTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
requires('gui')
cls.root = Tk()
cls.root.withdraw()
@classmethod
def tearDownClass(cls):
cls.root.destroy()
del cls.root
def setUp(self):
self.code = compile(TEST_CODE, 'test_stackviewer.py', 'exec')
self.stack = [
(MockFrame(self.code, 1), 1),
(MockFrame(self.code, 2), 2)
]
# Create a stackviewer and load the test stack.
self.sv = debugger.StackViewer(self.root, None, None)
self.sv.load_stack(self.stack)
def test_init(self):
# Test creation of StackViewer.
gui = None
flist = None
master_window = self.root
sv = debugger.StackViewer(master_window, flist, gui)
self.assertTrue(hasattr(sv, 'stack'))
def test_load_stack(self):
# Test the .load_stack() method against a fixed test stack.
# Check the test stack is assigned and the list contains the repr of them.
self.assertEqual(self.sv.stack, self.stack)
self.assertTrue('?.<module>(), line 1:' in self.sv.get(0))
self.assertEqual(self.sv.get(1), '?.<module>(), line 2: ')
def test_show_source(self):
# Test the .show_source() method against a fixed test stack.
# Patch out the file list to monitor it
self.sv.flist = Mock()
# Patch out isfile to pretend file exists.
with patch('idlelib.debugger.os.path.isfile', return_value=True) as isfile:
self.sv.show_source(1)
isfile.assert_called_once_with('test_stackviewer.py')
self.sv.flist.open.assert_called_once_with('test_stackviewer.py')
class NameSpaceTest(unittest.TestCase):
@ -23,7 +291,5 @@ def test_init(self):
debugger.NamespaceViewer(self.root, 'Test')
# Other classes are Idb, Debugger, and StackViewer.
if __name__ == '__main__':
unittest.main(verbosity=2)

View file

@ -133,8 +133,8 @@ class PyShellEditorWindow(EditorWindow):
def __init__(self, *args):
self.breakpoints = []
EditorWindow.__init__(self, *args)
self.text.bind("<<set-breakpoint-here>>", self.set_breakpoint_here)
self.text.bind("<<clear-breakpoint-here>>", self.clear_breakpoint_here)
self.text.bind("<<set-breakpoint>>", self.set_breakpoint_event)
self.text.bind("<<clear-breakpoint>>", self.clear_breakpoint_event)
self.text.bind("<<open-python-shell>>", self.flist.open_shell)
#TODO: don't read/write this from/to .idlerc when testing
@ -155,8 +155,8 @@ def filename_changed_hook(old_hook=self.io.filename_change_hook,
("Copy", "<<copy>>", "rmenu_check_copy"),
("Paste", "<<paste>>", "rmenu_check_paste"),
(None, None, None),
("Set Breakpoint", "<<set-breakpoint-here>>", None),
("Clear Breakpoint", "<<clear-breakpoint-here>>", None)
("Set Breakpoint", "<<set-breakpoint>>", None),
("Clear Breakpoint", "<<clear-breakpoint>>", None)
]
def color_breakpoint_text(self, color=True):
@ -181,11 +181,11 @@ def set_breakpoint(self, lineno):
self.breakpoints.append(lineno)
try: # update the subprocess debugger
debug = self.flist.pyshell.interp.debugger
debug.set_breakpoint_here(filename, lineno)
debug.set_breakpoint(filename, lineno)
except: # but debugger may not be active right now....
pass
def set_breakpoint_here(self, event=None):
def set_breakpoint_event(self, event=None):
text = self.text
filename = self.io.filename
if not filename:
@ -194,7 +194,7 @@ def set_breakpoint_here(self, event=None):
lineno = int(float(text.index("insert")))
self.set_breakpoint(lineno)
def clear_breakpoint_here(self, event=None):
def clear_breakpoint_event(self, event=None):
text = self.text
filename = self.io.filename
if not filename:
@ -209,7 +209,7 @@ def clear_breakpoint_here(self, event=None):
"insert lineend +1char")
try:
debug = self.flist.pyshell.interp.debugger
debug.clear_breakpoint_here(filename, lineno)
debug.clear_breakpoint(filename, lineno)
except:
pass

View file

@ -1,3 +1,5 @@
# Rename to stackbrowser or possibly consolidate with browser.
import linecache
import os

View file

@ -0,0 +1,4 @@
Add docstrings to the IDLE debugger module. Fix two bugs:
initialize Idb.botframe (should be in Bdb); in Idb.in_rpc_code,
check whether prev_frame is None before trying to use it.
Greatly expand test_debugger.