From e04b90b9e188e054f8965ab4191d39ffd10e7702 Mon Sep 17 00:00:00 2001 From: Philip DePetro Date: Tue, 26 May 2026 14:17:06 -0700 Subject: [PATCH] Return evaluate result in DAP response body instead of writing to stdout When a DAP client sends an `evaluate` request with `context: "repl"` and no `frameId`, debugpy forces the expression through the exec code path. Previously, if the expression could be compiled as an eval (e.g. `2 + 2`), `evaluate_expression` would compute the result but write it to `sys.stdout` and return `None`. The caller in `internal_evaluate_expression_json` would then send back `result=""` in the response body. Clients that read the response body would get nothing, while the actual value was emitted as a DAP output event. This changes `evaluate_expression` to return the computed result from the eval-within-exec path instead of printing it. The caller now captures that return value and includes it in the response body. Pure exec statements (e.g. `x = 42`) continue to return `None` and produce `result=""` as before. VS Code is unaffected because it always provides a `frameId`, which routes through the normal eval path where results already go into the response body. --- .../pydevd/_pydevd_bundle/pydevd_comm.py | 10 +++-- .../pydevd/_pydevd_bundle/pydevd_vars.py | 13 +++--- .../pydevd/tests_python/test_debugger_json.py | 6 +-- .../tests_python/test_evaluate_expression.py | 41 ++++++++++++++----- 4 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_comm.py b/src/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_comm.py index 1998b7c6f..352bb5327 100644 --- a/src/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_comm.py +++ b/src/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_comm.py @@ -1239,12 +1239,16 @@ def __create_frame(): if try_exec: try: - pydevd_vars.evaluate_expression(py_db, frame, expression, is_exec=True) + exec_result = pydevd_vars.evaluate_expression(py_db, frame, expression, is_exec=True) except (Exception, KeyboardInterrupt): _evaluate_response_return_exception(py_db, request, *sys.exc_info()) return - # No result on exec. - _evaluate_response(py_db, request, result="") + # Use simple string formatting rather than the richer obtain_as_variable path + # (which provides type info, variablesReference, etc.) because the exec path is + # typically reached when there is no frameId, meaning thread_id="*" and no + # frame_tracker is available to build a structured variable response. + result = "%s" % (exec_result,) if exec_result is not None else "" + _evaluate_response(py_db, request, result=result) return # Ok, we have the result (could be an error), let's put it into the saved variables. diff --git a/src/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_vars.py b/src/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_vars.py index 0f3d6250b..cc0fc2804 100644 --- a/src/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_vars.py +++ b/src/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_vars.py @@ -473,9 +473,9 @@ def method(): There are some changes in this function depending on whether it's an exec or an eval. When it's an exec (i.e.: is_exec==True): - This function returns None. + If the expression can be compiled as an eval, the result of the evaluation is returned. + If the expression can only be compiled as an exec (i.e.: a statement), None is returned. Any exception that happens during the evaluation is reraised. - If the expression could actually be evaluated, the variable is printed to the console if not None. When it's an eval (i.e.: is_exec==False): This function returns the result from the evaluation. @@ -539,12 +539,13 @@ def method(): if is_exec: try: - # Try to make it an eval (if it is an eval we can print it, otherwise we'll exec it and - # it will have whatever the user actually did) + # Try to make it an eval (if it is an eval we can return the result to the caller, + # otherwise we'll exec it and it will have whatever the user actually did) compiled = compile_as_eval(expression) except Exception: compiled = None + result = None if compiled is None: try: compiled = _compile_as_exec(expression) @@ -574,9 +575,7 @@ def method(): result = t.evaluated_value else: result = eval(compiled, updated_globals, updated_locals) - if result is not None: # Only print if it's not None (as python does) - sys.stdout.write("%s\n" % (result,)) - return + return result else: ret = eval_in_context(expression, updated_globals, updated_locals, py_db) diff --git a/src/debugpy/_vendored/pydevd/tests_python/test_debugger_json.py b/src/debugpy/_vendored/pydevd/tests_python/test_debugger_json.py index f665c0b29..a4a8c0261 100644 --- a/src/debugpy/_vendored/pydevd/tests_python/test_debugger_json.py +++ b/src/debugpy/_vendored/pydevd/tests_python/test_debugger_json.py @@ -3633,9 +3633,9 @@ def test_evaluate(case_setup_dap): # Ok, 'foo_value' is now set in 'email' module. exec_response = json_facade.evaluate("email.foo_value") - # We don't actually get variables without a frameId, we can just evaluate and observe side effects - # (so, the result is always empty -- or an error). - assert exec_response.body.result == "" + # Without a frameId the exec path is used, which now returns the result + # for eval-able expressions. + assert exec_response.body.result == "True" json_facade.write_continue() diff --git a/src/debugpy/_vendored/pydevd/tests_python/test_evaluate_expression.py b/src/debugpy/_vendored/pydevd/tests_python/test_evaluate_expression.py index 1e7695333..a58722353 100644 --- a/src/debugpy/_vendored/pydevd/tests_python/test_evaluate_expression.py +++ b/src/debugpy/_vendored/pydevd/tests_python/test_evaluate_expression.py @@ -238,23 +238,44 @@ async def main(): assert async_call is not None # Make sure it's in the locals. frame = sys._getframe() eval_txt = "await async_call(10)" - from io import StringIO + result = evaluate_expression(py_db, frame, eval_txt, is_exec=True) - _original_stdout = sys.stdout - try: - stringio = sys.stdout = StringIO() - evaluate_expression(py_db, frame, eval_txt, is_exec=True) - finally: - sys.stdout = _original_stdout - - # I.e.: Check that we printed the value obtained in the exec. - assert "10\n" in stringio.getvalue() + # The eval-within-exec path should return the result directly. + assert result == 10 import asyncio asyncio.run(main()) +@pytest.mark.skipif(IS_PY313_0, reason="Crashes on Python 3.13.0") +def test_evaluate_expression_sync_exec_as_eval(disable_critical_log): + from _pydevd_bundle.pydevd_vars import evaluate_expression + from io import StringIO + + frame = next(iter(obtain_frame())) + + # An expression evaluated via the exec path should return the result directly + # and must not write to stdout. + _original_stdout = sys.stdout + try: + stringio = sys.stdout = StringIO() + result = evaluate_expression(None, frame, "1 + 2", is_exec=True) + finally: + sys.stdout = _original_stdout + assert result == 3 + assert stringio.getvalue() == "" + + # None results should return None. + result = evaluate_expression(None, frame, "None", is_exec=True) + assert result is None + + # Statements (pure exec) should return None. + result = evaluate_expression(None, frame, "x = 42", is_exec=True) + assert result is None + assert frame.f_locals["x"] == 42 + + @pytest.mark.skipif(not CAN_EVALUATE_TOP_LEVEL_ASYNC or IS_PY313_0, reason="Requires top-level async evaluation. Crashes on Python 3.13.0") def test_evaluate_expression_async_exec_error(disable_critical_log): py_db = _DummyPyDB()