diff --git a/Doc/library/pdb.rst b/Doc/library/pdb.rst index 6a401a1af7eba3..1e0b5038a9749b 100644 --- a/Doc/library/pdb.rst +++ b/Doc/library/pdb.rst @@ -753,7 +753,7 @@ can be overridden by the local file. .. pdbcommand:: ! statement - Execute the (one-line) *statement* in the context of the current stack frame. + Execute the *statement* in the context of the current stack frame. The exclamation point can be omitted unless the first word of the statement resembles a debugger command, e.g.: @@ -770,6 +770,11 @@ can be overridden by the local file. (Pdb) global list_options; list_options = ['-l'] (Pdb) + .. versionchanged:: next + Multiple Python statements can be entered together at a single prompt, + for example by pasting a block of code when using the + :ref:`enhanced interactive shell `. + .. pdbcommand:: run [args ...] restart [args ...] diff --git a/Lib/pdb.py b/Lib/pdb.py index db48c00d2f477d..9ecf96af4a2c66 100644 --- a/Lib/pdb.py +++ b/Lib/pdb.py @@ -65,10 +65,12 @@ # NOTE: the actual command documentation is collected from docstrings of the # commands and is appended to __doc__ after the class has been defined. +import __future__ import os import io import sys import cmd +import ast import dis import bdb import code @@ -313,6 +315,55 @@ def namespace(self): ) +def _compile_command(source, filename=''): + try: + return codeop.compile_command(source, filename, 'single') + except SyntaxError: + # The input may contain multiple statements, for example after a paste. + if codeop.compile_command(source, filename, 'exec') is None: + return None + tree = ast.parse(source, filename, 'exec') + return compile(ast.Interactive(tree.body), filename, 'single') + + +# Match interactive execution, including expressions in compound suites, +# without changing the bodies of functions and classes defined by the user. +class _PdbCodeTransformer(ast.NodeTransformer): + def __init__(self): + self.annotated_names = set() + + def visit_Expr(self, node): + display = ast.parse("__pdb_eval__['displayhook'](None)").body[0] + display.value.args[0] = node.value + return ast.copy_location(display, node) + + def visit_AnnAssign(self, node): + if isinstance(node.target, ast.Name): + self.annotated_names.add(node.target.id) + return self.generic_visit(node) + + def visit_ImportFrom(self, node): + if node.module != "__future__": + return node + # Preserve the import's runtime bindings as well as its flags. + names = tuple(alias.name for alias in node.names) + statements = [ast.parse( + f"__pdb_eval__['future'] = " + f"__pdb_eval__['import_future']({names!r}, " + f"__pdb_eval__['locals']())" + ).body[0]] + statements.extend(ast.parse( + f"{alias.asname or alias.name} = " + f"__pdb_eval__['future'].{alias.name}" + ).body[0] for alias in node.names) + return [ast.copy_location(stmt, node) for stmt in statements] + + def visit_FunctionDef(self, node): + return node + + visit_AsyncFunctionDef = visit_ClassDef = visit_FunctionDef + + class _PdbInteractiveConsole(code.InteractiveConsole): def __init__(self, ns=None, message=None): self._message = message @@ -985,9 +1036,13 @@ def _exec_in_closure(self, source, globals, locals): returns True if the source is executed, False otherwise """ + # At module scope, nested code can already access all names as globals. + if locals is globals: + return False + # Determine if the source should be executed in closure. Only when the # source compiled to multiple code objects, we should use this feature. - # Otherwise, we can just raise an exception and normal exec will be used. + # Otherwise, normal exec can be used. code = compile(source, "", "exec") if not any(isinstance(const, CodeType) for const in code.co_consts): @@ -996,25 +1051,48 @@ def _exec_in_closure(self, source, globals, locals): # locals could be a proxy which does not support pop # copy it first to avoid modifying the original locals locals_copy = dict(locals) + original_names = set(locals_copy) + + def import_future(names, scope_locals): + frame_builtins = globals.get("__builtins__", builtins) + try: + importer = (frame_builtins["__import__"] + if isinstance(frame_builtins, dict) + else frame_builtins.__import__) + except (KeyError, AttributeError): + raise ImportError("__import__ not found") from None + return importer("__future__", globals, scope_locals, names, 0) locals_copy["__pdb_eval__"] = { - "result": None, - "write_back": {} + "displayhook": self.displayhook, + "locals": builtins.locals, + "initial": locals_copy.copy(), + "import_future": import_future, + "write_back": None } - # If the source is an expression, we need to print its value - try: - compile(source, "", "eval") - except SyntaxError: - pass - else: - source = "__pdb_eval__['result'] = " + source + transformer = _PdbCodeTransformer() + tree = transformer.visit(ast.parse(source)) + annotated_names = transformer.annotated_names + compiler_flags = code.co_flags & sum( + getattr(__future__, name).compiler_flag + for name in __future__.all_feature_names + ) + source = ast.unparse(tree) + + # Annotated names cannot be declared nonlocal. Keep their annotations + # in function scope and initialize existing values before user code, + # since an annotation without a value must not clear a local. + source = "\n".join( + f"{name} = __pdb_eval__['initial'][{name!r}]" + for name in sorted(annotated_names & original_names) + ) + "\n" + source # Add write-back to update the locals source = ("try:\n" + textwrap.indent(source, " ") + "\n" + "finally:\n" + - " __pdb_eval__['write_back'] = locals()") + " __pdb_eval__['write_back'] = __pdb_eval__['locals']()") # Build a closure source code with freevars from locals like: # def __pdb_outer(): @@ -1026,7 +1104,8 @@ def _exec_in_closure(self, source, globals, locals): source_with_closure = ("def __pdb_outer():\n" + "\n".join(f" {var} = None" for var in locals_copy) + "\n" + " def __pdb_scope():\n" + - "\n".join(f" nonlocal {var}" for var in locals_copy) + "\n" + + "\n".join(f" nonlocal {var}" for var in locals_copy + if var not in annotated_names) + "\n" + textwrap.indent(source, " ") + "\n" + " return __pdb_scope.__code__" ) @@ -1036,7 +1115,8 @@ def _exec_in_closure(self, source, globals, locals): # that to get the code object of __pdb_scope() ns = {} try: - exec(source_with_closure, {}, ns) + exec(compile(source_with_closure, "", "exec", + flags=compiler_flags, dont_inherit=True), {}, ns) except Exception: return False code = ns["__pdb_outer"]() @@ -1045,20 +1125,21 @@ def _exec_in_closure(self, source, globals, locals): try: exec(code, globals, locals_copy, closure=cells) - except Exception: - return False - - # get the data we need from the statement - pdb_eval = locals_copy["__pdb_eval__"] - - # __pdb_eval__ should not be updated back to locals - pdb_eval["write_back"].pop("__pdb_eval__") - - # Write all local variables back to locals - locals.update(pdb_eval["write_back"]) - eval_result = pdb_eval["result"] - if eval_result is not None: - self.message(repr(eval_result)) + finally: + # Preserve assignments made before an exception, without executing + # the source again through the normal exec path. + write_back = locals_copy["__pdb_eval__"]["write_back"] + if write_back is not None: + write_back.pop("__pdb_eval__", None) + for name in original_names - write_back.keys(): + try: + del locals[name] + except ValueError: + # Frame locals allow removing debugger-created names, + # but deliberately forbid removing compiled fast locals. + if not isinstance(locals, types.FrameLocalsProxyType): + raise + locals.update(write_back) return True @@ -1081,12 +1162,16 @@ def _read_code(self, line): is_await_code = False code = None try: - if (code := codeop.compile_command(line + '\n', '', 'single')) is None: + if (code := _compile_command( + self._replace_convenience_variables(line + '\n') + )) is None: # Multi-line mode with self._enable_multiline_input(): buffer = line continue_prompt = "... " - while (code := codeop.compile_command(buffer, '', 'single')) is None: + while (code := _compile_command( + self._replace_convenience_variables(buffer) + )) is None: if self.use_rawinput: try: line = input(continue_prompt) @@ -1121,7 +1206,7 @@ def _read_code(self, line): else: raise - return code, buffer, is_await_code + return code, self._replace_convenience_variables(buffer), is_await_code def default(self, line): if line[:1] == '!': line = line[1:].strip() @@ -1159,6 +1244,7 @@ def _replace_convenience_variables(self, line): if "$" not in line: return line + line_starts = [0] + [m.end() for m in re.finditer("\n", line)] dollar_start = dollar_end = (-1, -1) replace_variables = [] try: @@ -1167,10 +1253,12 @@ def _replace_convenience_variables(self, line): if token_type == token.OP and token_string == '$': dollar_start, dollar_end = start, end elif start == dollar_end and token_type == token.NAME: - # line is a one-line command so we only care about column - replace_variables.append((dollar_start[1], end[1], token_string)) + start_offset = line_starts[dollar_start[0] - 1] + dollar_start[1] + end_offset = line_starts[end[0] - 1] + end[1] + replace_variables.append((start_offset, end_offset, token_string)) except tokenize.TokenError: - return line + # Incomplete input may still contain tokens that can be replaced. + pass if not replace_variables: return line @@ -3273,8 +3361,10 @@ def default(self, line): # Unlike Pdb, don't prompt for more lines of a multi-line command. # The remote needs to send us the whole block in one go. try: - candidate = line.removeprefix("!") + "\n" - if codeop.compile_command(candidate, "", "single") is None: + candidate = self._replace_convenience_variables( + line.removeprefix("!") + "\n" + ) + if _compile_command(candidate) is None: raise SyntaxError("Incomplete command") return super().default(candidate) except: @@ -3384,7 +3474,11 @@ def read_command(self, prompt): return reply prefix = "" + compiler = codeop.compile_command if self.state == "pdb": + def compiler(source, filename): + source = self.pdb_instance._replace_convenience_variables(source) + return _compile_command(source, filename) # PDB command entry mode cmd = self.pdb_instance.parseline(reply)[0] if cmd in self.pdb_commands or reply.strip() == "": @@ -3396,13 +3490,13 @@ def read_command(self, prompt): prefix = "!" reply = reply.removeprefix(prefix).lstrip() - if codeop.compile_command(reply + "\n", "", "single") is not None: - # Valid single-line statement + if compiler(reply + "\n", "") is not None: + # Complete Python input return prefix + reply # Otherwise, valid first line of a multi-line statement more_prompt = "...".ljust(len(prompt)) - while codeop.compile_command(reply, "", "single") is None: + while compiler(reply, "") is None: reply += "\n" + self.read_input(more_prompt, multiline_block=True) return prefix + reply diff --git a/Lib/test/test_pdb.py b/Lib/test/test_pdb.py index 51a3e2212a13cf..8a58f0f36c31c8 100644 --- a/Lib/test/test_pdb.py +++ b/Lib/test/test_pdb.py @@ -1268,6 +1268,35 @@ def test_convenience_variables(): """ +def test_convenience_variables_multiline_offsets(): + r"""Token replacements use absolute offsets across multiline source. + + >>> debugger = pdb.Pdb(nosigint=True, readrc=False) + >>> source = '$x +\n "$x" + $y\n' + >>> debugger._replace_convenience_variables(source) + '__pdb_convenience_variables["x"] +\n "$x" + __pdb_convenience_variables["y"]\n' + >>> source = "$x + '''ordinary\n$x literal''' + $y\n" + >>> result = debugger._replace_convenience_variables(source) + >>> '__pdb_convenience_variables["x"]' in result and '$x literal' in result and '__pdb_convenience_variables["y"]' in result + True + >>> source = '$x +\r\n $y\r\n' + >>> debugger._replace_convenience_variables(source) + '__pdb_convenience_variables["x"] +\r\n __pdb_convenience_variables["y"]\r\n' + >>> source = 'é = 1\n$x + $y\n' + >>> debugger._replace_convenience_variables(source) + 'é = 1\n__pdb_convenience_variables["x"] + __pdb_convenience_variables["y"]\n' + >>> source = 'f"value {$x}" + $y\n' + >>> debugger._replace_convenience_variables(source) + 'f"value {__pdb_convenience_variables["x"]}" + __pdb_convenience_variables["y"]\n' + >>> source = 'if True:\n $x\n $y\n' + >>> debugger._replace_convenience_variables(source) + 'if True:\n __pdb_convenience_variables["x"]\n __pdb_convenience_variables["y"]\n' + >>> source = 'value = (\n $x +\n 1\n)\n' + >>> debugger._replace_convenience_variables(source) + 'value = (\n __pdb_convenience_variables["x"] +\n 1\n)\n' + """ + + def test_post_mortem_chained(): """Test post mortem traceback debugging of chained exception @@ -2797,6 +2826,304 @@ def test_pdb_multiline_statement(): (Pdb) c """ +def test_pdb_multiple_statements(): + r"""Multiple statements can be submitted as a single command. + + >>> def test_function(): + ... value = 40 + ... debugger = pdb.Pdb(nosigint=True, readrc=False) + ... debugger.set_trace(commands=[ + ... 'class A:\n x = value\n\nclass B(A):\n pass\n\nB().x + 2', + ... 'p B().x', + ... 'def f():\n return value\n\nf()', + ... 'value = 1\nvalue += 1\nvalue', + ... '! value = 3\nvalue + 1', + ... 'continue', + ... ]) + ... return value + + >>> test_function() + 42 + 40 + 40 + 2 + 4 + 3 + """ + +def test_pdb_multiple_statements_module(): + r"""Module-level definitions should have ordinary qualified names. + + >>> namespace = {'__name__': '__main__'} + >>> debugger = pdb.Pdb(nosigint=True, readrc=False) + >>> debugger.cmdqueue = [ + ... 'class A:\n pass\n\nclass B(A):\n pass\n\nB()', + ... 'def f():\n return B()\n\nf()', + ... 'continue', + ... ] + >>> debugger.run('pass', namespace) # doctest: +ELLIPSIS + <__main__.B object at 0x...> + <__main__.B object at 0x...> + >>> namespace['A'].__qualname__ + 'A' + >>> namespace['B'].__qualname__ + 'B' + >>> namespace['f'].__qualname__ + 'f' + """ + +def test_pdb_multiple_statements_errors(): + r"""Syntax errors reject the block, and runtime errors stop execution. + + >>> def test_function(): + ... value = 0 + ... debugger = pdb.Pdb(nosigint=True, readrc=False) + ... debugger.set_trace(commands=[ + ... 'value = 1\ninvalid syntax', + ... 'value', + ... 'value = 2\n1 / 0\nvalue = 3', + ... 'value', + ... 'continue', + ... ]) + ... return value + + >>> test_function() + *** SyntaxError: invalid syntax + 0 + *** ZeroDivisionError: division by zero + 2 + 2 + """ + + +def test_convenience_variables_continuation(): + r"""Convenience variables can occur in incomplete continuation input. + + >>> debugger = pdb.Pdb(nosigint=True, readrc=False) + >>> debugger.stdin = io.StringIO(' $x +\n 1\n)\n') + >>> debugger.stdout = io.StringIO() + >>> debugger.use_rawinput = False + >>> code, source, is_await = debugger._read_code('value = (') + >>> namespace = {'__pdb_convenience_variables': {'x': 100}} + >>> exec(code, namespace) + >>> namespace['value'] + 101 + >>> '$x' in source + False + """ + + +def test_pdb_multiple_statements_closure(): + r"""Pasted code has the same effects and output in module and function frames. + + >>> def execute(source, module_scope): + ... output = io.StringIO() + ... debugger = pdb.Pdb(stdout=output, nosigint=True, readrc=False) + ... if module_scope: + ... namespace = {'__name__': '__main__', 'items': [], 'partial': None, 'locals': 42} + ... debugger.cmdqueue = [source, 'continue'] + ... debugger.run('pass', namespace) + ... return output.getvalue(), namespace['items'], namespace['partial'] + ... def test_function(): + ... items = [] + ... partial = None + ... locals = 42 + ... debugger.set_trace(commands=[source, 'continue']) + ... return items, partial + ... items, partial = test_function() + ... return output.getvalue(), items, partial + + A runtime error does not repeat side effects, lose earlier assignments, or + execute later statements. This also applies to BaseException subclasses, + and the local variable named "locals" must not interfere with writeback. + + >>> prefix = 'def f(): pass\nitems.append(1)\npartial = "written"\n' + >>> for error in ('1 / 0', 'raise KeyboardInterrupt', 'raise SystemExit(3)'): + ... source = prefix + error + '\nitems.append(2)' + ... module = execute(source, True) + ... nested = execute(source, False) + ... assert module == nested + ... print(nested) + ('*** ZeroDivisionError: division by zero\n', [1], 'written') + ('*** KeyboardInterrupt\n', [1], 'written') + ('*** SystemExit: 3\n', [1], 'written') + + Expressions in suites are displayed without changing defined bodies or + their docstrings. + + >>> source = ('class A:\n \'class doc\'\n 99\n' + ... 'for i in range(2):\n i\n' + ... 'def f():\n \'function doc\'\n 99\n return 10\n' + ... 'f()\nA.__doc__\nf.__doc__\n' + ... 'value = lambda: 20\nvalue()') + >>> module = execute(source, True) + >>> nested = execute(source, False) + >>> module == nested + True + >>> print(nested[0], end='') + 0 + 1 + 10 + 'class doc' + 'function doc' + 20 + """ + + + +def test_pdb_multiple_statements_actual_frames(): + r"""Pasted blocks use the paused frame's namespace and do not leak flags. + + >>> def execute(module_scope, commands, frame_builtins=None): + ... output = io.StringIO() + ... with redirect_stdout(output): + ... debugger = pdb.Pdb(stdout=output, nosigint=True, readrc=False) + ... if module_scope: + ... namespace = {'__name__': '__main__', 'value': 99, 'items': [None]} + ... if frame_builtins is not None: + ... namespace['__builtins__'] = frame_builtins + ... debugger.cmdqueue = commands + ['continue'] + ... debugger.run('pass', namespace) + ... return namespace, output.getvalue() + ... def test_function(): + ... value = 40 + ... items = [None] + ... with patch.dict(globals(), {'value': 99}): + ... debugger.set_trace(commands=commands + ['continue']) + ... return sys._getframe().f_locals.copy() + ... if frame_builtins is not None: + ... frame_globals = dict(test_function.__globals__, __builtins__=frame_builtins) + ... test_function = types.FunctionType( + ... test_function.__code__, frame_globals, + ... closure=test_function.__closure__) + ... return test_function(), output.getvalue() + + The nested definition must see the value assigned in the paused frame, + even when the module global has a different value. + + >>> source = ('value: int = 4\ndef get_value():\n return value\n' + ... 'observed = get_value()\nget_value()') + >>> for module_scope in (False, True): + ... result, output = execute(module_scope, [source]) + ... print(result['value'], result['observed'], output == '4\n') + 4 4 True + 4 4 True + + Annotation targets inside nested functions must not be mistaken for + assignments to the paused frame. Attribute and subscript target expressions + still run, while their annotation expressions are not evaluated. + + >>> for module_scope in (False, True): + ... result, _ = execute(module_scope, [ + ... 'events = []', + ... 'class Target: pass\n' + ... 'def inner():\n' + ... ' (events.append("attribute") or Target).attr: Unknown = 5\n' + ... ' items[events.append("subscript") or 0]: Unknown = 6\n' + ... ' value: Unknown\n return 1\n\n' + ... 'class Nested:\n value: int\n\n' + ... 'observed = inner()\noutside = value', + ... ]) + ... print(result['events'], result['outside']) + ['attribute', 'subscript'] 40 + ['attribute', 'subscript'] 99 + + Wrapper-level attribute and subscript annotations still evaluate their + targets when a later function forces the nested-code execution path. + + >>> for module_scope in (False, True): + ... result, _ = execute(module_scope, [ + ... 'events = []', + ... 'class Target: pass\n' + ... '(events.append("attribute") or Target).attr: Unknown = 5\n' + ... 'items[events.append("subscript") or 0]: Unknown = 6\n' + ... 'def marker(): pass', + ... ]) + ... print(result['events'], result['Target'].attr, result['items']) + ['attribute', 'subscript'] 5 [6] + ['attribute', 'subscript'] 5 [6] + + Future imports also bind their imported feature object, including aliases, + and replace a local with the same name. + + >>> import __future__ + >>> for module_scope in (False, True): + ... result, _ = execute(module_scope, [ + ... "feature = 'old'", + ... 'from __future__ import annotations as feature\n' + ... 'observed = (lambda: feature.compiler_flag)()', + ... ]) + ... print(result['feature'] is __future__.annotations, + ... result['observed'] == __future__.annotations.compiler_flag) + True True + True True + + Custom eager and lazy import hooks in the paused frame's builtins receive + the future import exactly once with its original fromlist and level. + + >>> import builtins + >>> calls = [] + >>> real_import = builtins.__import__ + >>> def tracking_import(name, globals=None, locals=None, fromlist=(), level=0): + ... if name == '__future__': + ... calls.append((name, fromlist, level)) + ... return real_import(name, globals, locals, fromlist, level) + >>> for module_scope in (False, True): + ... calls.clear() + ... frame_builtins = dict(vars(builtins), __import__=tracking_import, + ... __lazy_import__=tracking_import) + ... result, _ = execute(module_scope, [ + ... 'from __future__ import annotations as feature\n' + ... 'observed = (lambda: feature.compiler_flag)()', + ... ], frame_builtins) + ... assert calls == [('__future__', ('annotations',), 0)], (module_scope, calls) + ... assert result['feature'] is __future__.annotations + ... assert result['observed'] == __future__.annotations.compiler_flag + + Annotation-only assignment preserves the value. Future annotations affect + only that pasted input, and the function defined there sees the local. + + >>> for module_scope in (False, True): + ... result, output = execute(module_scope, [ + ... 'value: int = 4', + ... 'value: str\nobserved_annotation = (lambda: value)()', + ... 'from __future__ import annotations\ndef g(x: Unknown):\n' + ... ' return value\nobserved = g(None)', + ... 'def h(x: Unknown): pass', + ... 'p h.__annotations__', + ... ]) + ... print(result['value'], result['observed_annotation'], + ... result['observed'], result['g'].__annotations__, + ... 'NameError' in output) + 4 4 4 {'x': 'Unknown'} True + 4 4 4 {'x': 'Unknown'} True + + Scratch state can also be deleted successfully without an exception. + + >>> for module_scope in (False, True): + ... result, _ = execute(module_scope, [ + ... 'scratch = 123', + ... 'class A: pass\ndel scratch', + ... ]) + ... print('scratch' in result) + False + False + + An earlier command can create scratch state; deleting it in a pasted + block must persist when a later statement raises. Later statements must + remain unexecuted. + + >>> for module_scope in (False, True): + ... result, output = execute(module_scope, [ + ... 'scratch = 123', + ... 'class A: pass\nitems.append(1)\ndel scratch\n1 / 0\nafter = 1', + ... ]) + ... print('scratch' in result, 'after' in result, result['items'], + ... output == '*** ZeroDivisionError: division by zero\n') + False False [None, 1] True + False False [None, 1] True + """ + def test_pdb_closure(): """Test for all expressions/statements that involve closure @@ -4846,6 +5173,24 @@ def foo(self): stdout, stderr = self.run_pdb_script(script, commands) self.assertIn("The specified object 'C.foo' is not a function", stdout) + @unittest.skipIf(not pdb._pyrepl_available(), "pyrepl is not available") + def test_pyrepl_multiple_statements(self): + script = textwrap.dedent(""" + def f(): + value = 40 + import pdb; pdb.Pdb().set_trace() + f() + """) + input = b"class A:\nx = value\n" + # Remove the auto-indent to start another top-level statement. + input += b"\x08\x08\x08\x08class B(A):\npass\n" + input += b"\x08\x08\x08\x08B().x + 2\n\n" + input += b"c\n" + + output = run_pty(script, input) + self.assertIn(b'42', output) + self.assertNotIn(b'Error', output) + def test_pyrepl_available(self): with patch.dict(os.environ, {"PYTHON_BASIC_REPL": "1"}): self.assertFalse(pdb._pyrepl_available()) diff --git a/Lib/test/test_remote_pdb.py b/Lib/test/test_remote_pdb.py index e5387526090536..06aa4d8dc48b84 100644 --- a/Lib/test/test_remote_pdb.py +++ b/Lib/test/test_remote_pdb.py @@ -627,6 +627,38 @@ def test_multiline_completion_in_pdb_state(self): expected_state={"state": "pdb"}, ) + def test_multiple_statements_in_pdb_state(self): + source = "class A:\n pass\n\nclass B(A):\n pass\n\nB()" + for prefix in ("", "! "): + with self.subTest(prefix=prefix): + incoming = [ + ("server", {"prompt": "(Pdb) ", "state": "pdb"}), + ("user", {"prompt": "(Pdb) ", "input": prefix + source}), + ] + self.do_test( + incoming=incoming, + expected_outgoing=[{"reply": prefix.strip() + source}], + expected_state={"state": "pdb"}, + ) + + def test_convenience_variable_in_multiline_pdb_input(self): + source = "value = (\n $x +\n 1\n)" + for lines in ([source], source.splitlines()): + with self.subTest(lines=lines): + incoming = [ + ("server", {"prompt": "(Pdb) ", "state": "pdb"}), + ("user", {"prompt": "(Pdb) ", "input": lines[0]}), + ] + incoming.extend( + ("user", {"prompt": "... ", "input": line}) + for line in lines[1:] + ) + self.do_test( + incoming=incoming, + expected_outgoing=[{"reply": source}], + expected_state={"state": "pdb"}, + ) + def test_completion_in_interact_state(self): """Test requesting tab completions at a >>> prompt.""" incoming = [ @@ -943,6 +975,25 @@ def test_interact_mode(self): self.pdb._run_in_python_repl("if:") mock_error.assert_called_once() + def test_multiple_statements(self): + source = "class A:\n x = c\n\nclass B(A):\n pass\n\nB().x" + self.pdb.default(source) + self.assertEqual(self.sockfile.get_output(), [ + {"message": "3\n", "type": "info"}, + ]) + self.assertEqual(self.test_locals['B']().x, 3) + + def test_multiple_statements_incomplete(self): + self.pdb.default("value = 1\nclass A:") + self.assertEqual(self.sockfile.get_output(), [ + {"message": "SyntaxError: Incomplete command", "type": "error"}, + ]) + self.assertNotIn('value', self.test_locals) + + def test_convenience_variable_on_continuation_line(self): + self.pdb.default("value = (\n $x +\n 1\n)") + self.assertEqual(self.test_locals['value'], 101) + def test_registering_commands(self): """Test registering breakpoint commands.""" # Mock get_bpbynumber diff --git a/Misc/NEWS.d/next/Library/2026-10-20-12-00-00.gh-issue-159887.e6jV7m.rst b/Misc/NEWS.d/next/Library/2026-10-20-12-00-00.gh-issue-159887.e6jV7m.rst new file mode 100644 index 00000000000000..f511c81b05a2d8 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-20-12-00-00.gh-issue-159887.e6jV7m.rst @@ -0,0 +1,3 @@ +Support entering multiple Python statements together at a single :mod:`pdb` +prompt, including class or function definitions followed by an expression. +Patch by Bartosz Sławecki.