diff --git a/Doc/library/pdb.rst b/Doc/library/pdb.rst index d80c5eebbf27a7..00caa97d212a2c 100644 --- a/Doc/library/pdb.rst +++ b/Doc/library/pdb.rst @@ -379,6 +379,14 @@ can be overridden by the local file. Execute the current line, stop at the first possible occasion (either in a function that is called or on the next line in the current function). +.. pdbcommand:: si | stepi + + Execute the current bytecode instruction, stop at the first possible occasion + (either in a function that is called or on the next instruction in the + current function). + + .. versionadded:: 3.12 + .. pdbcommand:: n(ext) Continue execution until the next line in the current function is reached or @@ -387,6 +395,13 @@ can be overridden by the local file. executes called functions at (nearly) full speed, only stopping at the next line in the current function.) +.. pdbcommand:: ni | nexti + + Continue execution until the next bytecode instruction in the current function + is reached or it returns. + + .. versionadded:: 3.12 + .. pdbcommand:: unt(il) [lineno] Without argument, continue execution until the line with a number greater @@ -433,6 +448,13 @@ can be overridden by the local file. .. versionadded:: 3.2 The ``>>`` marker. +.. pdbcommand:: li | listi [first[, last]] + + Similar to :pdbcmd:`list`, but also display bytecode instructions with + the source code + + .. versionadded:: 3.12 + .. pdbcommand:: ll | longlist List all source code for the current function or frame. Interesting lines @@ -440,6 +462,13 @@ can be overridden by the local file. .. versionadded:: 3.2 +.. pdbcommand:: lli | longlisti + + Similar to :pdbcmd:`ll`, but also display bytecode instructions with + the source code + + .. versionadded:: 3.12 + .. pdbcommand:: a(rgs) Print the argument list of the current function. diff --git a/Lib/bdb.py b/Lib/bdb.py index 7f9b09514ffd00..429771ded4c281 100644 --- a/Lib/bdb.py +++ b/Lib/bdb.py @@ -32,6 +32,9 @@ def __init__(self, skip=None): self.skip = set(skip) if skip else None self.breaks = {} self.fncache = {} + self._curframe = None + self.lasti = -1 + self.trace_opcodes = False self.frame_returning = None self._load_breaks() @@ -75,6 +78,7 @@ def trace_dispatch(self, frame, event, arg): is entered. return: A function or other code block is about to return. exception: An exception has occurred. + opcode: An opcode is going to be executed. c_call: A C function is about to be called. c_return: A C function has returned. c_exception: A C function has raised an exception. @@ -84,6 +88,8 @@ def trace_dispatch(self, frame, event, arg): The arg parameter depends on the previous event. """ + self._curframe = frame + if self.quitting: return # None if event == 'line': @@ -94,6 +100,8 @@ def trace_dispatch(self, frame, event, arg): return self.dispatch_return(frame, arg) if event == 'exception': return self.dispatch_exception(frame, arg) + if event == 'opcode': + return self.dispatch_opcode(frame) if event == 'c_call': return self.trace_dispatch if event == 'c_exception': @@ -115,6 +123,18 @@ def dispatch_line(self, frame): if self.quitting: raise BdbQuit return self.trace_dispatch + def dispatch_opcode(self, frame): + """Invoke user function and return trace function for opcode event. + + If the debugger stops on the current opcode, invoke + self.user_opcode(). Raise BdbQuit if self.quitting is set. + Return self.trace_dispatch to continue tracing in this scope. + """ + if self.stop_here(frame) or self.break_here(frame): + self.user_opcode(frame) + if self.quitting: raise BdbQuit + return self.trace_dispatch + def dispatch_call(self, frame, arg): """Invoke user function and return trace function for call event. @@ -122,6 +142,11 @@ def dispatch_call(self, frame, arg): self.user_call(). Raise BdbQuit if self.quitting is set. Return self.trace_dispatch to continue tracing in this scope. """ + if self.trace_opcodes: + frame.f_trace_opcodes = True + else: + frame.f_trace_opcodes = False + # XXX 'arg' is no longer used if self.botframe is None: # First call of dispatch since reset() @@ -209,9 +234,15 @@ def stop_here(self, frame): if frame is self.stopframe: if self.stoplineno == -1: return False - return frame.f_lineno >= self.stoplineno + if self.trace_opcodes: + return self.lasti != frame.f_lasti + else: + return frame.f_lineno >= self.stoplineno if not self.stopframe: - return True + if self.trace_opcodes: + return self.lasti != frame.f_lasti + else: + return True return False def break_here(self, frame): @@ -272,7 +303,21 @@ def user_exception(self, frame, exc_info): """Called when we stop on an exception.""" pass - def _set_stopinfo(self, stopframe, returnframe, stoplineno=0): + def user_opcode(self, frame): + """Called when we are about to execute an opcode.""" + pass + + def _set_trace_opcodes(self, trace_opcodes): + if trace_opcodes != self.trace_opcodes: + self.trace_opcodes = trace_opcodes + frame = self._curframe + while frame is not None: + frame.f_trace_opcodes = trace_opcodes + if frame is self.botframe: + break + frame = frame.f_back + + def _set_stopinfo(self, stopframe, returnframe, stoplineno=0, lasti=None): """Set the attributes for stopping. If stoplineno is greater than or equal to 0, then stop at line @@ -285,6 +330,22 @@ def _set_stopinfo(self, stopframe, returnframe, stoplineno=0): # stoplineno >= 0 means: stop at line >= the stoplineno # stoplineno -1 means: don't stop at all self.stoplineno = stoplineno + if lasti: + # We are stopping at opcode level + self._set_trace_opcodes(True) + self.lasti = lasti + else: + self._set_trace_opcodes(False) + + def _set_caller_tracefunc(self): + # Issue #13183: pdb skips frames after hitting a breakpoint and running + # step commands. + # Restore the trace function in the caller (that may not have been set + # for performance reasons) when returning from the current frame. + if self.frame_returning: + caller_frame = self.frame_returning.f_back + if caller_frame and not caller_frame.f_trace: + caller_frame.f_trace = self.trace_dispatch # Derived classes and clients can call the following methods # to affect the stepping state. @@ -299,20 +360,22 @@ def set_until(self, frame, lineno=None): def set_step(self): """Stop after one line of code.""" - # Issue #13183: pdb skips frames after hitting a breakpoint and running - # step commands. - # Restore the trace function in the caller (that may not have been set - # for performance reasons) when returning from the current frame. - if self.frame_returning: - caller_frame = self.frame_returning.f_back - if caller_frame and not caller_frame.f_trace: - caller_frame.f_trace = self.trace_dispatch + self._set_caller_tracefunc() self._set_stopinfo(None, None) + def set_stepi(self, frame): + """Stop after one opcode.""" + self._set_caller_tracefunc() + self._set_stopinfo(None, None, lasti=frame.f_lasti) + def set_next(self, frame): """Stop on the next line in or below the given frame.""" self._set_stopinfo(frame, None) + def set_nexti(self, frame): + """Stop on the next line in or below the given frame.""" + self._set_stopinfo(frame, None, lasti=frame.f_lasti) + def set_return(self, frame): """Stop when returning from the given frame.""" if frame.f_code.co_flags & GENERATOR_AND_COROUTINE_FLAGS: diff --git a/Lib/pdb.py b/Lib/pdb.py index 3a06cd00ad2bf1..3ff9eb1dc9adb7 100755 --- a/Lib/pdb.py +++ b/Lib/pdb.py @@ -203,6 +203,7 @@ def namespace(self): # command "pdb.line_prefix = '\n% '". # line_prefix = ': ' # Use this to get the old situation back line_prefix = '\n-> ' # Probably a better default +inst_prefix = '\n--> ' # Probably a better default class Pdb(bdb.Bdb, cmd.Cmd): @@ -330,6 +331,15 @@ def user_line(self, frame): if self.bp_commands(frame): self.interaction(frame, None) + def user_opcode(self, frame): + """This function is called when we are about to execute an opcode.""" + if self._wait_for_mainpyfile: + if (self.mainpyfile != self.canonic(frame.f_code.co_filename) + or frame.f_lineno <= 0): + return + self._wait_for_mainpyfile = False + self.interaction(frame, None) + def bp_commands(self, frame): """Call every command that was set for the current active breakpoint (if there is one). @@ -422,6 +432,8 @@ def interaction(self, frame, traceback): self.forget() return self.print_stack_entry(self.stack[self.curindex]) + if self.trace_opcodes: + self.print_current_inst(frame) self._cmdloop() self.forget() @@ -1087,15 +1099,34 @@ def do_step(self, arg): return 1 do_s = do_step + def do_stepi(self, arg): + """s(tep) + Execute the current bytecode instruction, stop at the first + possible occasion (either in a function that is called or in + the current function). + """ + self.set_stepi(self.curframe) + return 1 + do_si = do_stepi + def do_next(self, arg): """n(ext) Continue execution until the next line in the current function - is reached or it returns. + is reached or the current function returns. """ self.set_next(self.curframe) return 1 do_n = do_next + def do_nexti(self, arg): + """n(ext) + Continue execution until the next bytecode instruction in the + current function is reached or the current function returns. + """ + self.set_nexti(self.curframe) + return 1 + do_ni = do_nexti + def do_run(self, arg): """run [args...] Restart the debugged python program. If a string is supplied @@ -1287,21 +1318,7 @@ def do_pp(self, arg): complete_p = _complete_expression complete_pp = _complete_expression - def do_list(self, arg): - """l(ist) [first [,last] | .] - - List source code for the current file. Without arguments, - list 11 lines around the current line or continue the previous - listing. With . as argument, list 11 lines around the current - line. With one argument, list 11 lines starting at that line. - With two arguments, list the given range; if the second - argument is less than the first, it is a count. - - The current line in the current frame is indicated by "->". - If an exception is being debugged, the line where the - exception was originally raised or propagated is indicated by - ">>", if it differs from the current line. - """ + def _do_list(self, arg, show_instructions=False): self.lastcmd = 'list' last = None if arg and arg != '.': @@ -1335,19 +1352,60 @@ def do_list(self, arg): breaklist = self.get_file_breaks(filename) try: lines = linecache.getlines(filename, self.curframe.f_globals) + instructions = dis.get_instructions(self.curframe.f_code, adaptive=True) \ + if show_instructions else None self._print_lines(lines[first-1:last], first, breaklist, - self.curframe) + self.curframe, + instructions) self.lineno = min(last, len(lines)) if len(lines) < last: self.message('[EOF]') except KeyboardInterrupt: pass + + def do_list(self, arg): + """l(ist) [first [,last] | .] + + List source code for the current file. Without arguments, + list 11 lines around the current line or continue the previous + listing. With . as argument, list 11 lines around the current + line. With one argument, list 11 lines starting at that line. + With two arguments, list the given range; if the second + argument is less than the first, it is a count. + + The current line in the current frame is indicated by "->". + If an exception is being debugged, the line where the + exception was originally raised or propagated is indicated by + ">>", if it differs from the current line. + """ + self._do_list(arg, False) do_l = do_list - def do_longlist(self, arg): - """longlist | ll - List the whole source code for the current function or frame. + def do_listi(self, arg): + """listi | li [first[, last] | .] + + List source code for the current file with bytecode + instructions. + + Without arguments, list 11 lines with their corresponding + instructions around the current line or continue the + previous listing. With . as argument, list 11 lines with + their corresponding instructions around the current line. + With one argument, list 11 lines with their corresponding + instructions starting at that line. With two arguments, + list the given range; if the second argument is less than + the first, it is a count. + + The current line in the current frame is indicated by "->". + The current instruction is indicated by "-->" + If an exception is being debugged, the line where the + exception was originally raised or propagated is indicated by + ">>", if it differs from the current line. """ + self._do_list(arg, True) + do_li = do_listi + + def _do_longlist(self, arg, show_instructions=False): filename = self.curframe.f_code.co_filename breaklist = self.get_file_breaks(filename) try: @@ -1355,9 +1413,27 @@ def do_longlist(self, arg): except OSError as err: self.error(err) return - self._print_lines(lines, lineno, breaklist, self.curframe) + instructions = dis.get_instructions(self.curframe.f_code, adaptive=True) \ + if show_instructions else None + self._print_lines(lines, lineno, breaklist, self.curframe, + instructions) + + def do_longlist(self, arg): + """longlist | ll + List the whole source code for the current function or frame. + """ + self._do_longlist(arg, False) do_ll = do_longlist + def do_longlisti(self, arg): + """longlisti | lli + + List the whole source code with bytecode instructions for + the current function or frame. + """ + self._do_longlist(arg, True) + do_lli = do_longlisti + def do_source(self, arg): """source expression Try to get source code for the given object and display it. @@ -1375,13 +1451,15 @@ def do_source(self, arg): complete_source = _complete_expression - def _print_lines(self, lines, start, breaks=(), frame=None): + def _print_lines(self, lines, start, breaks=(), frame=None, instructions=None): """Print a range of lines.""" if frame: current_lineno = frame.f_lineno exc_lineno = self.tb_lineno.get(frame, -1) else: current_lineno = exc_lineno = -1 + if instructions: + inst = next(instructions) for lineno, line in enumerate(lines, start): s = str(lineno).rjust(3) if len(s) < 4: @@ -1395,6 +1473,24 @@ def _print_lines(self, lines, start, breaks=(), frame=None): elif lineno == exc_lineno: s += '>>' self.message(s + '\t' + line.rstrip()) + if instructions: + # For the current line of the source code, get all the + # instructions belong to it. We keep a single iterator + # `instructions` for all the instructions compiled from + # the source and try to only go through the iterator once + while True: + if inst.positions.lineno == lineno: + current_inst = frame and frame.f_lasti == inst.offset + disassem = inst._disassemble(lineno_width=None, + mark_as_current=current_inst) + self.message(f" {disassem}") + elif inst.positions.lineno is not None and \ + inst.positions.lineno > lineno: + break + try: + inst = next(instructions) + except StopIteration: + break def do_whatis(self, arg): """whatis arg @@ -1559,6 +1655,13 @@ def print_stack_entry(self, frame_lineno, prompt_prefix=line_prefix): self.message(prefix + self.format_stack_entry(frame_lineno, prompt_prefix)) + def print_current_inst(self, frame): + for inst in dis.get_instructions(frame.f_code): + if inst.offset == frame.f_lasti: + self.message(inst._disassemble(lineno_width=None, + mark_as_current=True)) + return + # Provide help def do_help(self, arg): @@ -1668,10 +1771,11 @@ def _compile_error_message(self, expr): # unfortunately we can't guess this order from the class definition _help_order = [ 'help', 'where', 'down', 'up', 'break', 'tbreak', 'clear', 'disable', - 'enable', 'ignore', 'condition', 'commands', 'step', 'next', 'until', - 'jump', 'return', 'retval', 'run', 'continue', 'list', 'longlist', - 'args', 'p', 'pp', 'whatis', 'source', 'display', 'undisplay', - 'interact', 'alias', 'unalias', 'debug', 'quit', + 'enable', 'ignore', 'condition', 'commands', 'step', 'stepi', + 'next', 'nexti', 'until', 'jump', 'return', 'retval', 'run', + 'continue', 'list', 'listi', 'longlist', 'longlisti', 'args', 'p', + 'pp', 'whatis', 'source', 'display', 'undisplay', 'interact', 'alias', + 'unalias', 'debug', 'quit', ] for _command in _help_order: diff --git a/Lib/test/test_pdb.py b/Lib/test/test_pdb.py index de2bab46495729..735092f6d7ab22 100644 --- a/Lib/test/test_pdb.py +++ b/Lib/test/test_pdb.py @@ -1,5 +1,6 @@ # A test suite for pdb; not very comprehensive at the moment. +import dis import doctest import os import pdb @@ -2351,6 +2352,101 @@ def _create_fake_frozen_module(): # verify that pdb found the source of the "frozen" function self.assertIn('x = "Sentinel string for gh-93696"', stdout, "Sentinel statement not found") + def get_func_opnames(self, func_def, func_name): + extract_code = f""" + import dis + for inst in dis.get_instructions({func_name}): + print(inst.opname) + """ + + with redirect_stdout(StringIO()) as s: + exec(textwrap.dedent(func_def) + textwrap.dedent(extract_code)) + + return s.getvalue().splitlines() + + def test_list_instruction(self): + func_def = """ + def f(): + a = [1, 2, 3] + return a[0] + """ + func_exec = """ + f() + """ + script = func_def + func_exec + + commands_li = """ + break f + c + li + """ + + commands_lli = """ + break f + c + lli + """ + + # Make sure all the opcodes are listed + stdout, stderr = self.run_pdb_module(script, commands_li) + for opname in self.get_func_opnames(func_def, "f"): + self.assertIn(opname, stdout) + + stdout, stderr = self.run_pdb_module(script, commands_lli) + for opname in self.get_func_opnames(func_def, "f"): + self.assertIn(opname, stdout) + + def test_instruction_level_control(self): + func_def = """ + def f(): + a = [1, 2, 3] + return a[0] + """ + func_exec = """ + f() + """ + script = func_def + func_exec + + commands = """ + ni + li + """ + + # Check that after ni, current instruction is displayed + stdout, stderr = self.run_pdb_module(script, commands) + lines = [line.strip() for line in stdout.splitlines()] + for idx, line in enumerate(lines): + if "-->" in line: + # Found the current instruction indicator after ni + # Make sure that is listed in li + self.assertIn(line, lines[idx+1:]) + break + + commands = """ + ni + ni + ni + c + """ + + stdout, stderr = self.run_pdb_module(script, commands) + curr_instr_lines = [line.strip() for line in stdout.splitlines() if "-->" in line] + self.assertEqual(len(curr_instr_lines), 3) + for line in curr_instr_lines: + # Make sure ni is moving forward, not stopping at the same instruction + self.assertEqual(curr_instr_lines.count(line), 1) + + # this test is under the assumption that within 10 instructions the function + # f should be called + commands = "si\n" * 10 + "c\n" + + stdout, stderr = self.run_pdb_module(script, commands) + curr_instr_lines = [line.strip() for line in stdout.splitlines()] + # Make sure si stepped into the function so the users can see the source + # code of the function + self.assertTrue(any("-> a = [1, 2, 3]" in line for line in curr_instr_lines)) + + class ChecklineTests(unittest.TestCase): def setUp(self): linecache.clearcache() # Pdb.checkline() uses linecache.getline() diff --git a/Misc/NEWS.d/next/Library/2023-04-08-23-20-34.gh-issue-103049.PY_2cD.rst b/Misc/NEWS.d/next/Library/2023-04-08-23-20-34.gh-issue-103049.PY_2cD.rst new file mode 100644 index 00000000000000..f20de87713ba6f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-04-08-23-20-34.gh-issue-103049.PY_2cD.rst @@ -0,0 +1 @@ +Add instruction commands support for :mod:`pdb`