Files
windmill/debugger/dap_websocket_server.py
2026-01-13 15:20:06 +00:00

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")