963 lines
34 KiB
Python
963 lines
34 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Lightweight DAP (Debug Adapter Protocol) WebSocket Server for Python debugging.
|
|
|
|
This server acts as a bridge between a WebSocket client (Monaco editor) and Python's
|
|
built-in debugging capabilities using the bdb module.
|
|
|
|
It implements a minimal subset of DAP to support basic Python debugging:
|
|
- Setting breakpoints
|
|
- Stepping through code (step in, step over, step out, continue)
|
|
- Inspecting variables and stack frames
|
|
- Evaluating expressions
|
|
|
|
Usage:
|
|
python dap_websocket_server.py [--port PORT] [--host HOST]
|
|
"""
|
|
|
|
import asyncio
|
|
import bdb
|
|
import json
|
|
import linecache
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import traceback
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from io import StringIO
|
|
from typing import Any
|
|
|
|
# Configure logging - level will be set based on --debug flag in main()
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
)
|
|
logger = logging.getLogger("dap_server")
|
|
|
|
try:
|
|
import websockets
|
|
from websockets.server import serve
|
|
except ImportError:
|
|
print("websockets package required. Install with: pip install websockets")
|
|
sys.exit(1)
|
|
|
|
|
|
class DAPMessageType(Enum):
|
|
REQUEST = "request"
|
|
RESPONSE = "response"
|
|
EVENT = "event"
|
|
|
|
|
|
@dataclass
|
|
class DAPMessage:
|
|
"""Represents a DAP protocol message."""
|
|
|
|
seq: int
|
|
type: str # 'request', 'response', 'event'
|
|
command: str = ""
|
|
event: str = ""
|
|
request_seq: int = 0
|
|
success: bool = True
|
|
message: str = ""
|
|
body: dict = field(default_factory=dict)
|
|
|
|
def to_dict(self) -> dict:
|
|
result = {"seq": self.seq, "type": self.type}
|
|
if self.type == "request":
|
|
result["command"] = self.command
|
|
if self.body:
|
|
result["arguments"] = self.body
|
|
elif self.type == "response":
|
|
result["request_seq"] = self.request_seq
|
|
result["command"] = self.command
|
|
result["success"] = self.success
|
|
if self.message:
|
|
result["message"] = self.message
|
|
if self.body:
|
|
result["body"] = self.body
|
|
elif self.type == "event":
|
|
result["event"] = self.event
|
|
if self.body:
|
|
result["body"] = self.body
|
|
return result
|
|
|
|
|
|
class WindmillDebugger(bdb.Bdb):
|
|
"""A debugger based on Python's bdb module."""
|
|
|
|
def __init__(self, session: "DebugSession"):
|
|
super().__init__()
|
|
self.session = session
|
|
self.main_thread = threading.current_thread()
|
|
self._wait_for_continue = threading.Event()
|
|
self._step_mode = None # None, 'over', 'in', 'out'
|
|
self._stop_requested = False
|
|
self._current_frame = None
|
|
self._loop = None
|
|
|
|
def stop_here(self, frame):
|
|
"""Override to only stop when in step mode, not by default."""
|
|
# By default, bdb.stop_here returns True when stopframe is None,
|
|
# which causes user_line to be called for every line.
|
|
# We only want to stop at lines when we're actively stepping.
|
|
if self._step_mode is None:
|
|
# Not stepping - only stop at breakpoints (handled by break_here in dispatch_line)
|
|
return False
|
|
return super().stop_here(frame)
|
|
|
|
def user_line(self, frame):
|
|
"""Called when we stop at a line."""
|
|
if self._stop_requested:
|
|
raise bdb.BdbQuit()
|
|
|
|
self._current_frame = frame
|
|
filename = self.canonic(frame.f_code.co_filename)
|
|
lineno = frame.f_lineno
|
|
|
|
logger.debug(f"user_line called: {filename}:{lineno}, breaks={self.get_all_breaks()}")
|
|
|
|
# Check if we should stop here
|
|
should_stop = False
|
|
reason = "step"
|
|
|
|
# Check breakpoints using bdb's built-in method
|
|
if self.break_here(frame):
|
|
should_stop = True
|
|
reason = "breakpoint"
|
|
logger.info(f"Breakpoint HIT at {filename}:{lineno}")
|
|
elif self._step_mode == 'in':
|
|
should_stop = True
|
|
reason = "step"
|
|
elif self._step_mode == 'over':
|
|
should_stop = True
|
|
reason = "step"
|
|
elif self._step_mode == 'out':
|
|
# Will be handled by user_return
|
|
pass
|
|
|
|
if should_stop:
|
|
self._step_mode = None
|
|
# Notify the client that we've stopped
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.session.send_event(
|
|
"stopped",
|
|
{
|
|
"reason": reason,
|
|
"threadId": 1,
|
|
"allThreadsStopped": True,
|
|
},
|
|
),
|
|
self._loop,
|
|
)
|
|
# Wait for continue/step command
|
|
self._wait_for_continue.clear()
|
|
self._wait_for_continue.wait()
|
|
|
|
if self._stop_requested:
|
|
raise bdb.BdbQuit()
|
|
|
|
def user_return(self, frame, return_value):
|
|
"""Called when a return is about to happen."""
|
|
if self._step_mode == 'out':
|
|
self._step_mode = None
|
|
self._current_frame = frame
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.session.send_event(
|
|
"stopped",
|
|
{
|
|
"reason": "step",
|
|
"threadId": 1,
|
|
"allThreadsStopped": True,
|
|
},
|
|
),
|
|
self._loop,
|
|
)
|
|
self._wait_for_continue.clear()
|
|
self._wait_for_continue.wait()
|
|
|
|
def user_exception(self, frame, exc_info):
|
|
"""Called when an exception occurs."""
|
|
exc_type, exc_value, exc_tb = exc_info
|
|
self._current_frame = frame
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.session.send_event(
|
|
"stopped",
|
|
{
|
|
"reason": "exception",
|
|
"threadId": 1,
|
|
"allThreadsStopped": True,
|
|
"text": str(exc_value),
|
|
},
|
|
),
|
|
self._loop,
|
|
)
|
|
self._wait_for_continue.clear()
|
|
self._wait_for_continue.wait()
|
|
|
|
def do_continue(self):
|
|
"""Continue execution."""
|
|
self._step_mode = None
|
|
self._wait_for_continue.set()
|
|
|
|
def do_step_over(self):
|
|
"""Step over (next line)."""
|
|
self._step_mode = 'over'
|
|
self.set_next(self._current_frame)
|
|
self._wait_for_continue.set()
|
|
|
|
def do_step_in(self):
|
|
"""Step into."""
|
|
self._step_mode = 'in'
|
|
self.set_step()
|
|
self._wait_for_continue.set()
|
|
|
|
def do_step_out(self):
|
|
"""Step out."""
|
|
self._step_mode = 'out'
|
|
self.set_return(self._current_frame)
|
|
self._wait_for_continue.set()
|
|
|
|
def do_stop(self):
|
|
"""Stop debugging."""
|
|
self._stop_requested = True
|
|
self._wait_for_continue.set()
|
|
|
|
def get_stack_frames(self) -> list[dict]:
|
|
"""Get current stack frames, stopping at <module> (user's script entry point)."""
|
|
frames = []
|
|
if self._current_frame is None:
|
|
return frames
|
|
|
|
frame = self._current_frame
|
|
frame_id = 1
|
|
while frame is not None:
|
|
filename = self.canonic(frame.f_code.co_filename)
|
|
name = frame.f_code.co_name
|
|
frames.append({
|
|
"id": frame_id,
|
|
"name": name,
|
|
"source": {"path": filename, "name": os.path.basename(filename)},
|
|
"line": frame.f_lineno,
|
|
"column": 0,
|
|
})
|
|
# Stop at <module> - don't include debugger/threading internals
|
|
if name == "<module>":
|
|
break
|
|
frame = frame.f_back
|
|
frame_id += 1
|
|
return frames
|
|
|
|
def get_frame_by_id(self, frame_id: int):
|
|
"""Get a frame by its ID."""
|
|
frame = self._current_frame
|
|
current_id = 1
|
|
while frame is not None:
|
|
if current_id == frame_id:
|
|
return frame
|
|
frame = frame.f_back
|
|
current_id += 1
|
|
return None
|
|
|
|
def get_locals(self, frame_id: int = 1) -> dict:
|
|
"""Get local variables for a frame."""
|
|
frame = self.get_frame_by_id(frame_id)
|
|
if frame:
|
|
return frame.f_locals.copy()
|
|
return {}
|
|
|
|
def get_globals(self, frame_id: int = 1) -> dict:
|
|
"""Get global variables for a frame."""
|
|
frame = self.get_frame_by_id(frame_id)
|
|
if frame:
|
|
return frame.f_globals.copy()
|
|
return {}
|
|
|
|
|
|
class DebugSession:
|
|
"""Manages a single debug session."""
|
|
|
|
def __init__(self, websocket, windmill_path: str | None = None):
|
|
self.websocket = websocket
|
|
self.windmill_path = windmill_path
|
|
self.seq = 1
|
|
self.initialized = False
|
|
self.configured = False
|
|
self.script_path: str | None = None
|
|
self.breakpoints: dict[str, list[int]] = {} # file -> line numbers
|
|
self.debug_thread: threading.Thread | None = None
|
|
self.debugger: WindmillDebugger | None = None
|
|
self._running = True
|
|
self._temp_file: str | None = None
|
|
self._variables_ref_counter = 1
|
|
self._scopes_map: dict[int, dict] = {} # ref -> {type, frame_id}
|
|
self._loop = asyncio.get_event_loop()
|
|
self._call_main = False
|
|
self._main_args: dict = {}
|
|
self._venv_path: str | None = None
|
|
|
|
def next_seq(self) -> int:
|
|
seq = self.seq
|
|
self.seq += 1
|
|
return seq
|
|
|
|
def prepare_dependencies(self, code: str) -> str | None:
|
|
"""
|
|
Prepare Python dependencies by calling the windmill CLI.
|
|
Returns the path to the venv's site-packages directory, or None if no dependencies needed.
|
|
"""
|
|
if not self.windmill_path:
|
|
logger.info("No windmill binary path configured, skipping dependency preparation")
|
|
return None
|
|
|
|
logger.info(f"Preparing dependencies using {self.windmill_path}")
|
|
|
|
try:
|
|
# Call the windmill CLI with the code
|
|
input_data = json.dumps({"code": code, "language": "python3"})
|
|
logger.debug(f"prepare-deps input: {input_data[:200]}...")
|
|
|
|
import time
|
|
start_time = time.time()
|
|
|
|
result = subprocess.run(
|
|
[self.windmill_path, "prepare-deps"],
|
|
input=input_data,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120, # 2 minute timeout for dependency installation
|
|
)
|
|
|
|
elapsed = time.time() - start_time
|
|
logger.info(f"prepare-deps completed in {elapsed:.2f}s (exit code: {result.returncode})")
|
|
|
|
if result.returncode != 0:
|
|
logger.error(f"prepare-deps failed (stderr): {result.stderr}")
|
|
logger.error(f"prepare-deps failed (stdout): {result.stdout}")
|
|
return None
|
|
|
|
# Log raw output for debugging
|
|
logger.debug(f"prepare-deps stdout: {result.stdout[:500] if result.stdout else '(empty)'}")
|
|
if result.stderr:
|
|
logger.debug(f"prepare-deps stderr: {result.stderr[:500]}")
|
|
|
|
# Parse the response - may have "Running in standalone mode" prefix
|
|
output = result.stdout.strip()
|
|
# Find the JSON part (starts with '{')
|
|
json_start = output.find('{')
|
|
if json_start == -1:
|
|
logger.error(f"No JSON in prepare-deps output: {output}")
|
|
return None
|
|
|
|
json_str = output[json_start:]
|
|
response = json.loads(json_str)
|
|
logger.debug(f"prepare-deps response: {response}")
|
|
|
|
if not response.get("success"):
|
|
logger.error(f"prepare-deps error: {response.get('error')}")
|
|
return None
|
|
|
|
venv_path = response.get("venv_path")
|
|
cached = response.get("cached", False)
|
|
|
|
if venv_path:
|
|
if cached:
|
|
logger.info(f"Dependencies loaded from cache: {venv_path}")
|
|
else:
|
|
logger.info(f"Dependencies freshly installed at: {venv_path}")
|
|
else:
|
|
logger.info("No external dependencies detected in code")
|
|
|
|
return venv_path
|
|
|
|
except subprocess.TimeoutExpired:
|
|
logger.error("prepare-deps timed out after 120s")
|
|
return None
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"Failed to parse prepare-deps JSON output: {e}")
|
|
logger.error(f"Raw output was: {output[:500] if 'output' in dir() else '(not available)'}")
|
|
return None
|
|
except Exception as e:
|
|
logger.exception(f"Error preparing dependencies: {e}")
|
|
return None
|
|
|
|
def _next_var_ref(self) -> int:
|
|
ref = self._variables_ref_counter
|
|
self._variables_ref_counter += 1
|
|
return ref
|
|
|
|
async def send_message(self, msg: DAPMessage) -> None:
|
|
"""Send a DAP message to the client."""
|
|
data = json.dumps(msg.to_dict())
|
|
logger.debug(f"Sending: {data}")
|
|
await self.websocket.send(data)
|
|
|
|
async def send_response(
|
|
self,
|
|
request: dict,
|
|
success: bool = True,
|
|
body: dict | None = None,
|
|
message: str = "",
|
|
) -> None:
|
|
"""Send a response to a request."""
|
|
msg = DAPMessage(
|
|
seq=self.next_seq(),
|
|
type="response",
|
|
command=request.get("command", ""),
|
|
request_seq=request.get("seq", 0),
|
|
success=success,
|
|
message=message,
|
|
body=body or {},
|
|
)
|
|
await self.send_message(msg)
|
|
|
|
async def send_event(self, event: str, body: dict | None = None) -> None:
|
|
"""Send an event to the client."""
|
|
msg = DAPMessage(
|
|
seq=self.next_seq(),
|
|
type="event",
|
|
event=event,
|
|
body=body or {},
|
|
)
|
|
await self.send_message(msg)
|
|
|
|
async def handle_initialize(self, request: dict) -> None:
|
|
"""Handle the 'initialize' request."""
|
|
capabilities = {
|
|
"supportsConfigurationDoneRequest": True,
|
|
"supportsFunctionBreakpoints": False,
|
|
"supportsConditionalBreakpoints": False,
|
|
"supportsHitConditionalBreakpoints": False,
|
|
"supportsEvaluateForHovers": True,
|
|
"exceptionBreakpointFilters": [],
|
|
"supportsStepBack": False,
|
|
"supportsSetVariable": False,
|
|
"supportsRestartFrame": False,
|
|
"supportsGotoTargetsRequest": False,
|
|
"supportsStepInTargetsRequest": False,
|
|
"supportsCompletionsRequest": False,
|
|
"supportsModulesRequest": False,
|
|
"supportsExceptionOptions": False,
|
|
"supportsValueFormattingOptions": False,
|
|
"supportsExceptionInfoRequest": False,
|
|
"supportTerminateDebuggee": True,
|
|
"supportsDelayedStackTraceLoading": False,
|
|
"supportsLoadedSourcesRequest": False,
|
|
"supportsLogPoints": False,
|
|
"supportsTerminateThreadsRequest": False,
|
|
"supportsSetExpression": False,
|
|
"supportsTerminateRequest": True,
|
|
"supportsDataBreakpoints": False,
|
|
"supportsReadMemoryRequest": False,
|
|
"supportsDisassembleRequest": False,
|
|
"supportsCancelRequest": False,
|
|
"supportsBreakpointLocationsRequest": False,
|
|
}
|
|
await self.send_response(request, body=capabilities)
|
|
self.initialized = True
|
|
await self.send_event("initialized")
|
|
|
|
async def handle_set_breakpoints(self, request: dict) -> None:
|
|
"""Handle the 'setBreakpoints' request."""
|
|
args = request.get("arguments", {})
|
|
source = args.get("source", {})
|
|
source_path = source.get("path", "")
|
|
breakpoints_data = args.get("breakpoints", [])
|
|
|
|
verified_breakpoints = []
|
|
line_numbers = []
|
|
|
|
for bp in breakpoints_data:
|
|
line = bp.get("line", 0)
|
|
line_numbers.append(line)
|
|
verified_breakpoints.append(
|
|
{
|
|
"id": len(verified_breakpoints) + 1,
|
|
"verified": True,
|
|
"line": line,
|
|
"source": source,
|
|
}
|
|
)
|
|
|
|
# Store breakpoints - they'll be applied when launch is called
|
|
self.breakpoints[source_path] = line_numbers
|
|
logger.info(f"Stored breakpoints at lines {line_numbers} for {source_path}")
|
|
|
|
# If debugger already exists and we have a script path, update breakpoints now
|
|
if self.debugger and self.script_path:
|
|
self.debugger.clear_all_breaks()
|
|
for line in line_numbers:
|
|
self.debugger.set_break(self.script_path, line)
|
|
logger.info(f"Updated breakpoint at {self.script_path}:{line}")
|
|
|
|
await self.send_response(request, body={"breakpoints": verified_breakpoints})
|
|
|
|
async def handle_configuration_done(self, request: dict) -> None:
|
|
"""Handle the 'configurationDone' request."""
|
|
self.configured = True
|
|
await self.send_response(request)
|
|
|
|
async def handle_launch(self, request: dict) -> None:
|
|
"""Handle the 'launch' request."""
|
|
args = request.get("arguments", {})
|
|
self.script_path = args.get("program")
|
|
code = args.get("code", "")
|
|
cwd = args.get("cwd", os.getcwd())
|
|
self._call_main = args.get("callMain", False)
|
|
self._main_args = args.get("args", {})
|
|
self._env_vars = args.get("env", {})
|
|
|
|
if self._env_vars:
|
|
logger.info(f"Launch with env vars: {list(self._env_vars.keys())}")
|
|
|
|
if not self.script_path and not code:
|
|
await self.send_response(
|
|
request, success=False, message="No program or code specified"
|
|
)
|
|
return
|
|
|
|
# Prepare dependencies before modifying the code
|
|
if code:
|
|
self._venv_path = self.prepare_dependencies(code)
|
|
|
|
# If callMain is True, append a call to main() with the provided args
|
|
if self._call_main and code:
|
|
# Generate the main() call with kwargs
|
|
args_str = ", ".join(f"{k}={repr(v)}" for k, v in self._main_args.items())
|
|
code = code + f"\n\n# Auto-generated call to main entrypoint\n__windmill_result__ = main({args_str})\n"
|
|
logger.info(f"Added main() call with args: {args_str}")
|
|
|
|
# If code is provided, write it to a temp file
|
|
if code and not self.script_path:
|
|
fd, self._temp_file = tempfile.mkstemp(suffix=".py", prefix="windmill_debug_")
|
|
with os.fdopen(fd, "w") as f:
|
|
f.write(code)
|
|
self.script_path = self._temp_file
|
|
|
|
await self.send_response(request)
|
|
|
|
# Create debugger
|
|
self.debugger = WindmillDebugger(self)
|
|
self.debugger._loop = self._loop
|
|
|
|
# Set breakpoints in the debugger using the actual script path
|
|
# (breakpoints from frontend may use a different path like /tmp/script.py)
|
|
self.debugger.clear_all_breaks()
|
|
canonical_path = self.debugger.canonic(self.script_path)
|
|
logger.info(f"Script path: {self.script_path}, canonical: {canonical_path}")
|
|
logger.info(f"Stored breakpoints from frontend: {self.breakpoints}")
|
|
|
|
for file_path, lines in self.breakpoints.items():
|
|
logger.info(f"Processing breakpoints for frontend path '{file_path}': lines {lines}")
|
|
for line in lines:
|
|
# Use the actual script path, not the frontend path
|
|
error = self.debugger.set_break(self.script_path, line)
|
|
if error:
|
|
logger.error(f"Failed to set breakpoint at {self.script_path}:{line}: {error}")
|
|
else:
|
|
logger.info(f"Set breakpoint at {self.script_path}:{line}")
|
|
|
|
# Log all registered breakpoints for debugging
|
|
logger.info(f"Debugger breaks after setup: {self.debugger.get_all_breaks()}")
|
|
|
|
# Start debugging in a separate thread
|
|
self.debug_thread = threading.Thread(
|
|
target=self._run_script,
|
|
args=(self.script_path, cwd),
|
|
daemon=True,
|
|
)
|
|
self.debug_thread.start()
|
|
|
|
def _run_script(self, script_path: str, cwd: str) -> None:
|
|
"""Run the script with the debugger."""
|
|
old_cwd = os.getcwd()
|
|
old_argv = sys.argv
|
|
old_stdout = sys.stdout
|
|
old_stderr = sys.stderr
|
|
old_env = {}
|
|
old_sys_path = sys.path.copy()
|
|
|
|
# Add venv site-packages to sys.path if dependencies were prepared
|
|
if self._venv_path:
|
|
sys.path.insert(0, self._venv_path)
|
|
logger.info(f"Added {self._venv_path} to sys.path")
|
|
|
|
# Set environment variables for the script
|
|
if hasattr(self, '_env_vars') and self._env_vars:
|
|
for key, value in self._env_vars.items():
|
|
old_env[key] = os.environ.get(key)
|
|
os.environ[key] = str(value)
|
|
logger.info(f"Set {len(self._env_vars)} env vars for script")
|
|
|
|
# Create a streaming output wrapper that sends output events in real-time
|
|
session = self
|
|
loop = self._loop
|
|
|
|
class StreamingOutput:
|
|
def __init__(self, category: str):
|
|
self.category = category
|
|
self.buffer = ""
|
|
|
|
def write(self, data: str) -> int:
|
|
if data:
|
|
# Send output event immediately
|
|
asyncio.run_coroutine_threadsafe(
|
|
session.send_event("output", {"category": self.category, "output": data}),
|
|
loop,
|
|
)
|
|
return len(data)
|
|
|
|
def flush(self):
|
|
pass
|
|
|
|
streaming_stdout = StreamingOutput("stdout")
|
|
streaming_stderr = StreamingOutput("stderr")
|
|
|
|
try:
|
|
os.chdir(cwd)
|
|
sys.argv = [script_path]
|
|
sys.stdout = streaming_stdout
|
|
sys.stderr = streaming_stderr
|
|
|
|
# Read and compile the script
|
|
with open(script_path) as f:
|
|
code = f.read()
|
|
|
|
logger.info(f"Running script: {script_path}")
|
|
logger.info(f"Script content ({len(code)} chars):\n{code[:500]}...")
|
|
|
|
# Clear linecache to ensure fresh source
|
|
linecache.checkcache(script_path)
|
|
|
|
compiled = compile(code, script_path, "exec")
|
|
logger.info(f"Compiled code filename: {compiled.co_filename}")
|
|
|
|
# Create globals
|
|
globals_dict = {
|
|
"__name__": "__main__",
|
|
"__file__": script_path,
|
|
"__builtins__": __builtins__,
|
|
}
|
|
|
|
# Run with debugger
|
|
logger.info(f"Starting debugger.run() with breaks: {self.debugger.get_all_breaks()}")
|
|
self.debugger.run(compiled, globals_dict)
|
|
logger.info("debugger.run() completed normally")
|
|
|
|
# Script completed normally - get the result from main()
|
|
result = globals_dict.get("__windmill_result__")
|
|
logger.info(f"Script result: {result}")
|
|
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.send_event("terminated", {"result": result}),
|
|
self._loop,
|
|
)
|
|
|
|
except bdb.BdbQuit:
|
|
# Normal termination via stop
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.send_event("terminated"),
|
|
self._loop,
|
|
)
|
|
except Exception as e:
|
|
error_msg = traceback.format_exc()
|
|
logger.exception("Error running script")
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.send_event("output", {"category": "stderr", "output": error_msg}),
|
|
self._loop,
|
|
)
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.send_event("terminated", {"error": str(e)}),
|
|
self._loop,
|
|
)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
sys.argv = old_argv
|
|
sys.stdout = old_stdout
|
|
sys.stderr = old_stderr
|
|
sys.path = old_sys_path
|
|
# Restore environment variables
|
|
for key, old_value in old_env.items():
|
|
if old_value is None:
|
|
os.environ.pop(key, None)
|
|
else:
|
|
os.environ[key] = old_value
|
|
self._cleanup_temp_file()
|
|
|
|
def _cleanup_temp_file(self) -> None:
|
|
"""Clean up temporary file if created."""
|
|
if self._temp_file and os.path.exists(self._temp_file):
|
|
try:
|
|
os.unlink(self._temp_file)
|
|
except OSError:
|
|
pass
|
|
self._temp_file = None
|
|
|
|
async def handle_threads(self, request: dict) -> None:
|
|
"""Handle the 'threads' request."""
|
|
threads = [{"id": 1, "name": "MainThread"}]
|
|
await self.send_response(request, body={"threads": threads})
|
|
|
|
async def handle_stack_trace(self, request: dict) -> None:
|
|
"""Handle the 'stackTrace' request."""
|
|
if self.debugger:
|
|
stack_frames = self.debugger.get_stack_frames()
|
|
else:
|
|
stack_frames = []
|
|
await self.send_response(
|
|
request, body={"stackFrames": stack_frames, "totalFrames": len(stack_frames)}
|
|
)
|
|
|
|
async def handle_scopes(self, request: dict) -> None:
|
|
"""Handle the 'scopes' request."""
|
|
frame_id = request.get("arguments", {}).get("frameId", 1)
|
|
|
|
# Create scope references
|
|
local_ref = self._next_var_ref()
|
|
global_ref = self._next_var_ref()
|
|
|
|
self._scopes_map[local_ref] = {"type": "locals", "frame_id": frame_id}
|
|
self._scopes_map[global_ref] = {"type": "globals", "frame_id": frame_id}
|
|
|
|
scopes = [
|
|
{
|
|
"name": "Locals",
|
|
"variablesReference": local_ref,
|
|
"expensive": False,
|
|
},
|
|
{
|
|
"name": "Globals",
|
|
"variablesReference": global_ref,
|
|
"expensive": True,
|
|
},
|
|
]
|
|
await self.send_response(request, body={"scopes": scopes})
|
|
|
|
async def handle_variables(self, request: dict) -> None:
|
|
"""Handle the 'variables' request."""
|
|
variables_ref = request.get("arguments", {}).get("variablesReference", 0)
|
|
variables = []
|
|
|
|
scope_info = self._scopes_map.get(variables_ref)
|
|
if scope_info and self.debugger:
|
|
frame_id = scope_info["frame_id"]
|
|
if scope_info["type"] == "locals":
|
|
var_dict = self.debugger.get_locals(frame_id)
|
|
else:
|
|
var_dict = self.debugger.get_globals(frame_id)
|
|
|
|
for name, value in var_dict.items():
|
|
# Skip private/magic attributes for globals
|
|
if scope_info["type"] == "globals" and name.startswith("_"):
|
|
continue
|
|
try:
|
|
value_str = repr(value)
|
|
if len(value_str) > 100:
|
|
value_str = value_str[:97] + "..."
|
|
variables.append({
|
|
"name": name,
|
|
"value": value_str,
|
|
"type": type(value).__name__,
|
|
"variablesReference": 0,
|
|
})
|
|
except Exception:
|
|
variables.append({
|
|
"name": name,
|
|
"value": "<error getting value>",
|
|
"type": "unknown",
|
|
"variablesReference": 0,
|
|
})
|
|
|
|
await self.send_response(request, body={"variables": variables})
|
|
|
|
async def handle_evaluate(self, request: dict) -> None:
|
|
"""Handle the 'evaluate' request."""
|
|
args = request.get("arguments", {})
|
|
expression = args.get("expression", "")
|
|
frame_id = args.get("frameId", 1)
|
|
|
|
try:
|
|
if self.debugger:
|
|
frame = self.debugger.get_frame_by_id(frame_id)
|
|
if frame:
|
|
result = eval(expression, frame.f_globals, frame.f_locals)
|
|
result_str = repr(result)
|
|
else:
|
|
result_str = "<no frame>"
|
|
else:
|
|
result_str = eval(expression)
|
|
result_str = repr(result_str)
|
|
|
|
await self.send_response(
|
|
request,
|
|
body={
|
|
"result": result_str,
|
|
"variablesReference": 0,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
await self.send_response(
|
|
request,
|
|
body={
|
|
"result": f"Error: {e}",
|
|
"variablesReference": 0,
|
|
},
|
|
)
|
|
|
|
async def handle_continue(self, request: dict) -> None:
|
|
"""Handle the 'continue' request."""
|
|
if self.debugger:
|
|
self.debugger.do_continue()
|
|
await self.send_response(request, body={"allThreadsContinued": True})
|
|
|
|
async def handle_next(self, request: dict) -> None:
|
|
"""Handle the 'next' (step over) request."""
|
|
if self.debugger:
|
|
self.debugger.do_step_over()
|
|
await self.send_response(request)
|
|
|
|
async def handle_step_in(self, request: dict) -> None:
|
|
"""Handle the 'stepIn' request."""
|
|
if self.debugger:
|
|
self.debugger.do_step_in()
|
|
await self.send_response(request)
|
|
|
|
async def handle_step_out(self, request: dict) -> None:
|
|
"""Handle the 'stepOut' request."""
|
|
if self.debugger:
|
|
self.debugger.do_step_out()
|
|
await self.send_response(request)
|
|
|
|
async def handle_pause(self, request: dict) -> None:
|
|
"""Handle the 'pause' request."""
|
|
await self.send_response(request)
|
|
await self.send_event(
|
|
"stopped",
|
|
{
|
|
"reason": "pause",
|
|
"threadId": 1,
|
|
"allThreadsStopped": True,
|
|
},
|
|
)
|
|
|
|
async def handle_disconnect(self, request: dict) -> None:
|
|
"""Handle the 'disconnect' request."""
|
|
self._running = False
|
|
if self.debugger:
|
|
self.debugger.do_stop()
|
|
self._cleanup_temp_file()
|
|
await self.send_response(request)
|
|
|
|
async def handle_terminate(self, request: dict) -> None:
|
|
"""Handle the 'terminate' request."""
|
|
self._running = False
|
|
if self.debugger:
|
|
self.debugger.do_stop()
|
|
self._cleanup_temp_file()
|
|
await self.send_response(request)
|
|
await self.send_event("terminated")
|
|
|
|
async def handle_request(self, request: dict) -> None:
|
|
"""Route and handle a DAP request."""
|
|
command = request.get("command", "")
|
|
logger.debug(f"Handling command: {command}")
|
|
|
|
handlers = {
|
|
"initialize": self.handle_initialize,
|
|
"setBreakpoints": self.handle_set_breakpoints,
|
|
"configurationDone": self.handle_configuration_done,
|
|
"launch": self.handle_launch,
|
|
"threads": self.handle_threads,
|
|
"stackTrace": self.handle_stack_trace,
|
|
"scopes": self.handle_scopes,
|
|
"variables": self.handle_variables,
|
|
"evaluate": self.handle_evaluate,
|
|
"continue": self.handle_continue,
|
|
"next": self.handle_next,
|
|
"stepIn": self.handle_step_in,
|
|
"stepOut": self.handle_step_out,
|
|
"pause": self.handle_pause,
|
|
"disconnect": self.handle_disconnect,
|
|
"terminate": self.handle_terminate,
|
|
}
|
|
|
|
handler = handlers.get(command)
|
|
if handler:
|
|
await handler(request)
|
|
else:
|
|
logger.warning(f"Unhandled command: {command}")
|
|
await self.send_response(
|
|
request, success=False, message=f"Unsupported command: {command}"
|
|
)
|
|
|
|
|
|
# Module-level variable to store windmill binary path
|
|
_windmill_path: str | None = None
|
|
|
|
|
|
async def handle_connection(websocket) -> None:
|
|
"""Handle a WebSocket connection."""
|
|
session = DebugSession(websocket, windmill_path=_windmill_path)
|
|
logger.info(f"New connection from {websocket.remote_address}")
|
|
|
|
try:
|
|
async for message in websocket:
|
|
try:
|
|
data = json.loads(message)
|
|
logger.debug(f"Received: {data}")
|
|
|
|
if data.get("type") == "request":
|
|
await session.handle_request(data)
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"Invalid JSON: {e}")
|
|
except Exception as e:
|
|
logger.exception(f"Error handling message: {e}")
|
|
|
|
except websockets.exceptions.ConnectionClosed:
|
|
logger.info("Connection closed")
|
|
finally:
|
|
if session.debugger:
|
|
session.debugger.do_stop()
|
|
session._cleanup_temp_file()
|
|
|
|
|
|
async def main(host: str = "localhost", port: int = 5679, windmill_path: str | None = None) -> None:
|
|
"""Start the DAP WebSocket server."""
|
|
global _windmill_path
|
|
_windmill_path = windmill_path
|
|
|
|
if windmill_path:
|
|
logger.info(f"Windmill binary path: {windmill_path}")
|
|
logger.info(f"Starting DAP WebSocket server on ws://{host}:{port}")
|
|
|
|
async with serve(handle_connection, host, port):
|
|
await asyncio.Future() # Run forever
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description="DAP WebSocket Server for Python debugging")
|
|
parser.add_argument("--host", default="localhost", help="Host to bind to")
|
|
parser.add_argument("--port", type=int, default=5679, help="Port to listen on")
|
|
parser.add_argument("--windmill", help="Path to windmill binary for dependency preparation (or set WINDMILL_PATH env var)")
|
|
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
|
args = parser.parse_args()
|
|
|
|
# Set logging level based on --debug flag
|
|
if args.debug:
|
|
logging.getLogger().setLevel(logging.DEBUG)
|
|
logger.setLevel(logging.DEBUG)
|
|
logger.debug("Debug logging enabled")
|
|
|
|
# Use --windmill arg, or fall back to WINDMILL_PATH env var
|
|
windmill_path = args.windmill or os.environ.get("WINDMILL_PATH")
|
|
|
|
try:
|
|
asyncio.run(main(args.host, args.port, windmill_path))
|
|
except KeyboardInterrupt:
|
|
logger.info("Server stopped")
|