diff --git a/examples/launch.json b/examples/launch.json new file mode 100644 index 00000000000..a6d7ee7cd84 --- /dev/null +++ b/examples/launch.json @@ -0,0 +1,20 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Remote Attach", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ] + } + ] +} \ No newline at end of file diff --git a/launch.json b/launch.json new file mode 100644 index 00000000000..a6d7ee7cd84 --- /dev/null +++ b/launch.json @@ -0,0 +1,20 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Remote Attach", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ] + } + ] +} \ No newline at end of file diff --git a/marimo/_ast/compiler.py b/marimo/_ast/compiler.py index 645e401c52d..bc098f1db6b 100644 --- a/marimo/_ast/compiler.py +++ b/marimo/_ast/compiler.py @@ -35,6 +35,9 @@ else: from typing import TypeAlias +# TODO: Hack for POC +lookup = {} + LOGGER = _loggers.marimo_logger() Cls: TypeAlias = type @@ -213,12 +216,19 @@ def compile_cell( expr.end_col_offset = final_expr.end_col_offset # type: ignore[attr-defined] filename: str + print(source_position) if source_position: + print("source_position details") # Modify the "source" position for meaningful stacktraces fix_source_position(module, source_position) fix_source_position(expr, source_position) filename = source_position.filename + lookup[cell_id] = source_position else: + print("caching cell code") + raise NotImplementedError( + "Source position is required for non-anonymous files." + ) # store the cell's code in Python's linecache so debuggers can find it filename = get_filename(cell_id) # cache the entire cell's code, doesn't need to be done in source case @@ -296,24 +306,24 @@ def get_source_position( f: Cls | Callable[..., Any], lineno: int, col_offset: int ) -> Optional[SourcePosition]: # Fallback won't capture embedded scripts - if inspect.isclass(f): - is_script = f.__module__ == "__main__" - # Could be something wrapped in a decorator, like - # functools._lru_cache_wrapper. - elif hasattr(f, "__wrapped__"): - return get_source_position(f.__wrapped__, lineno, col_offset) - # Larger catch all than if inspect.isfunction(f): - elif hasattr(f, "__globals__") and hasattr(f, "__name__"): - is_script = f.__globals__["__name__"] == "__main__" # type: ignore - else: - return None - # TODO: spec is None for markdown notebooks, which is fine for now - if module := inspect.getmodule(f): - spec = module.__spec__ - is_script = spec is None or spec.name != "marimo_app" - - if not is_script: - return None + # if inspect.isclass(f): + # is_script = f.__module__ == "__main__" + # # Could be something wrapped in a decorator, like + # # functools._lru_cache_wrapper. + # elif hasattr(f, "__wrapped__"): + # return get_source_position(f.__wrapped__, lineno, col_offset) + # # Larger catch all than if inspect.isfunction(f): + # elif hasattr(f, "__globals__") and hasattr(f, "__name__"): + # is_script = f.__globals__["__name__"] == "__main__" # type: ignore + # else: + # return None + # # TODO: spec is None for markdown notebooks, which is fine for now + # if module := inspect.getmodule(f): + # spec = module.__spec__ + # is_script = spec is None or spec.name != "marimo_app" + + # if not is_script: + # return None return SourcePosition( filename=inspect.getfile(f), @@ -461,10 +471,11 @@ def cell_factory( # anonymous file is required for deterministic testing. source_position = None - if not anonymous_file: - source_position = get_source_position( - f, lnum + cell_def.lineno - 1, cell_def.col_offset - ) + print("anonymous_file", anonymous_file) + # if not anonymous_file: + source_position = get_source_position( + f, lnum + cell_def.lineno - 1, cell_def.col_offset + ) cell = compile_cell( cell_def.code, diff --git a/marimo/_ast/load.py b/marimo/_ast/load.py index 8f1d37f78cf..6bafa1d7573 100644 --- a/marimo/_ast/load.py +++ b/marimo/_ast/load.py @@ -165,16 +165,16 @@ def load_app(filename: Optional[str]) -> Optional[App]: elif not path.suffix == ".py": raise MarimoFileError("File must end with .py or .md") - try: - return _static_load(path) - except MarimoFileError: - # Security advantages of static load are lost here, but reasonable - # fallback for now. - _app = _dynamic_load(filename) - LOGGER.warning( - "Static loading of notebook failed; " - "falling back to dynamic loading. " - "If you can, please report this issue to the marimo team — " - "https://github.com/marimo-team/marimo/issues/new?template=bug_report.yaml" - ) - return _app + # try: + # return _static_load(path) + # except MarimoFileError: + # Security advantages of static load are lost here, but reasonable + # fallback for now. + _app = _dynamic_load(filename) + LOGGER.warning( + "Static loading of notebook failed; " + "falling back to dynamic loading. " + "If you can, please report this issue to the marimo team — " + "https://github.com/marimo-team/marimo/issues/new?template=bug_report.yaml" + ) + return _app diff --git a/marimo/_runtime/runtime.py b/marimo/_runtime/runtime.py index fb5c1a20dae..97a6e001430 100644 --- a/marimo/_runtime/runtime.py +++ b/marimo/_runtime/runtime.py @@ -840,8 +840,13 @@ def _try_compiling_cell( ) -> tuple[Optional[CellImpl], Optional[Error]]: error: Optional[Error] = None try: + from marimo._ast.compiler import lookup + cell = compile_cell( - code, cell_id=cell_id, carried_imports=carried_imports + code, + cell_id=cell_id, + carried_imports=carried_imports, + source_position=lookup.get(cell_id, None), ) except Exception as e: cell = None diff --git a/marimo/_server/api/lifespans.py b/marimo/_server/api/lifespans.py index f9cf2e225a9..66f14bb5fd4 100644 --- a/marimo/_server/api/lifespans.py +++ b/marimo/_server/api/lifespans.py @@ -93,6 +93,48 @@ async def mcp(app: Starlette) -> AsyncIterator[None]: LOGGER.error(f"Error during MCP cleanup: {e}") +@contextlib.asynccontextmanager +async def debug_server(app: Starlette) -> AsyncIterator[None]: + state = AppState.from_app(app) + session_mgr = state.session_manager + + dap_server = None # Track DAP server for cleanup + + # Only start the debug server in Edit mode + if session_mgr.mode == SessionMode.EDIT: + try: + from marimo._server.debug.dap_server import get_dap_server + from marimo._server.utils import find_free_port + + dap_server = get_dap_server(session_mgr) + LOGGER.info("Starting DAP debug server") + + # Find a free port for debug server + debug_port = find_free_port(5678, addr="localhost") + + actual_port = await dap_server.start( + host="localhost", port=debug_port + ) + LOGGER.info(f"DAP debug server started on localhost:{actual_port}") + + # Store the port in app state for middleware + app.state.debug_port = actual_port + + except Exception as e: + LOGGER.warning(f"Failed to start DAP debug server: {e}") + + yield + + # Clean up DAP server on shutdown + if dap_server: + try: + LOGGER.info("Stopping DAP debug server") + await dap_server.stop() + LOGGER.info("DAP debug server stopped") + except Exception as e: + LOGGER.error(f"Error during DAP server cleanup: {e}") + + @contextlib.asynccontextmanager async def open_browser(app: Starlette) -> AsyncIterator[None]: state = AppState.from_app(app) diff --git a/marimo/_server/debug/dap_server.py b/marimo/_server/debug/dap_server.py new file mode 100644 index 00000000000..d69288e356b --- /dev/null +++ b/marimo/_server/debug/dap_server.py @@ -0,0 +1,964 @@ +# Copyright 2024 Marimo. All rights reserved. +from __future__ import annotations + +import asyncio +import json +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, List, Optional + +from marimo import _loggers + +LOGGER = _loggers.marimo_logger() + + +class DAPMessageType(Enum): + REQUEST = "request" + RESPONSE = "response" + EVENT = "event" + + +class DAPRequestType(Enum): + INITIALIZE = "initialize" + ATTACH = "attach" + SET_BREAKPOINTS = "setBreakpoints" + CONTINUE = "continue" + STACK_TRACE = "stackTrace" + VARIABLES = "variables" + EVALUATE = "evaluate" + THREADS = "threads" + CONFIGURATION_DONE = "configurationDone" + LAUNCH = "launch" + DISCONNECT = "disconnect" + PAUSE = "pause" + STEP_IN = "stepIn" + STEP_OUT = "stepOut" + STEP_OVER = "next" + SOURCES = "sources" + SCOPES = "scopes" + EXCEPTION_INFO = "exceptionInfo" + + +class DAPEventType(Enum): + STOPPED = "stopped" + BREAKPOINT = "breakpoint" + + +@dataclass +class DAPMessage: + seq: int + type: DAPMessageType + command: Optional[str] = None + arguments: Optional[Dict[str, Any]] = None + request_seq: Optional[int] = None + success: Optional[bool] = None + message: Optional[str] = None + body: Optional[Dict[str, Any]] = None + event: Optional[str] = None + + +@dataclass +class Breakpoint: + line: int + verified: bool = True + message: Optional[str] = None + + +@dataclass +class DebugSession: + session_id: str + breakpoints: Dict[str, List[Breakpoint]] = None + + def __post_init__(self): + if self.breakpoints is None: + self.breakpoints = {} + + +class DAPTransport(ABC): + @abstractmethod + async def start(self, host: str, port: int) -> int: + pass + + @abstractmethod + async def stop(self) -> None: + pass + + @abstractmethod + async def send_message(self, message: DAPMessage) -> None: + pass + + +class TCPDAPTransport(DAPTransport): + def __init__(self): + self.server = None + self.running = False + self.clients = [] + self.message_handlers = [] + # Track each client's message format + self.client_formats = {} # (reader, writer) -> "http" or "length-prefixed" + + def add_message_handler(self, handler: callable) -> None: + """Add a message handler.""" + self.message_handlers.append(handler) + + async def start(self, host: str, port: int) -> int: + """Start the TCP server.""" + self.server = await asyncio.start_server( + self._handle_client, host, port + ) + self.running = True + actual_port = self.server.sockets[0].getsockname()[1] + LOGGER.info(f"TCP DAP transport started on {host}:{actual_port}") + return actual_port + + async def stop(self) -> None: + """Stop the TCP server.""" + self.running = False + if self.server: + self.server.close() + await self.server.wait_closed() + for reader, writer in self.clients: + writer.close() + await writer.wait_closed() + self.clients.clear() + self.client_formats.clear() + + async def send_message(self, message: DAPMessage) -> None: + """Send a message to all connected clients.""" + # Convert enum fields to strings for JSON serialization + message_dict = message.__dict__.copy() + if isinstance(message_dict.get("type"), DAPMessageType): + message_dict["type"] = message_dict["type"].value + + message_str = json.dumps(message_dict) + message_bytes = message_str.encode("utf-8") + + LOGGER.info( + f"Sending DAP response: type={message.type}, command={message.command}, seq={message.seq}" + ) + LOGGER.debug(f"Response content: {message_str}") + + for reader, writer in self.clients: + try: + client_format = self.client_formats.get( + (reader, writer), "length-prefixed" + ) + + if client_format == "http": + # Send in HTTP-style format + header = f"Content-Length: {len(message_bytes)}\r\n\r\n" + header_bytes = header.encode("utf-8") + writer.write(header_bytes + message_bytes) + LOGGER.debug("HTTP-style response sent to client") + else: + # Send in length-prefixed format + length_bytes = len(message_bytes).to_bytes(4, "big") + writer.write(length_bytes + message_bytes) + LOGGER.debug("Length-prefixed response sent to client") + + await writer.drain() + except Exception as e: + LOGGER.error(f"Error sending message to client: {e}") + + async def _handle_client( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + """Handle a new client connection.""" + self.clients.append((reader, writer)) + addr = writer.get_extra_info("peername") + LOGGER.info(f"New DAP client connected from {addr}") + + try: + while self.running: + # Read the first few bytes to see what VS Code is actually sending + try: + first_bytes = await reader.readexactly( + 20 + ) # Read first 20 bytes + LOGGER.info( + f"First 20 bytes from {addr}: {first_bytes} (hex: {first_bytes.hex()})" + ) + LOGGER.info( + f"First 20 bytes as string: {first_bytes.decode('utf-8', errors='ignore')}" + ) + + # Check if it starts with HTTP headers + if first_bytes.startswith(b"Content-Length:"): + LOGGER.info( + f"Detected HTTP-style DAP transport from {addr}" + ) + self.client_formats[(reader, writer)] = "http" + # Read the rest of the HTTP headers and body + await self._handle_http_style_messages( + reader, writer, addr, first_bytes + ) + # Continue reading more messages + continue + elif first_bytes.startswith(b"{"): + LOGGER.info(f"Detected JSON DAP transport from {addr}") + self.client_formats[(reader, writer)] = "json" + # Read the rest of the JSON message + await self._handle_json_messages( + reader, writer, addr, first_bytes + ) + # Continue reading more messages + continue + else: + # Assume length-prefixed format + LOGGER.info( + f"Detected length-prefixed DAP transport from {addr}" + ) + self.client_formats[(reader, writer)] = ( + "length-prefixed" + ) + # Try to parse as length-prefixed + await self._handle_length_prefixed_messages( + reader, writer, addr, first_bytes + ) + # Continue reading more messages + continue + + except asyncio.IncompleteReadError: + LOGGER.info( + f"DAP client {addr} disconnected (IncompleteReadError)" + ) + break + except Exception as e: + LOGGER.error( + f"Error determining message format from {addr}: {e}" + ) + break + + except asyncio.IncompleteReadError: + LOGGER.info( + f"DAP client {addr} disconnected (IncompleteReadError)" + ) + except ConnectionResetError: + LOGGER.info( + f"DAP client {addr} disconnected (ConnectionResetError)" + ) + except Exception as e: + LOGGER.error(f"Error handling DAP client {addr}: {e}") + finally: + if (reader, writer) in self.clients: + self.clients.remove((reader, writer)) + if (reader, writer) in self.client_formats: + del self.client_formats[(reader, writer)] + writer.close() + await writer.wait_closed() + LOGGER.info(f"DAP client {addr} connection closed") + + async def _handle_http_style_messages( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + addr, + initial_bytes: bytes, + ) -> None: + """Handle HTTP-style DAP messages.""" + LOGGER.info(f"Starting HTTP-style message handling for {addr}") + + # Read the rest of the HTTP headers + headers = initial_bytes.decode("utf-8", errors="ignore") + while True: + line = await reader.readline() + if not line: + break + line_str = line.decode("utf-8", errors="ignore") + headers += line_str + LOGGER.info(f"HTTP header: {line_str.strip()}") + if line_str.strip() == "": + break + + LOGGER.info(f"Complete HTTP headers: {headers}") + + # Extract Content-Length from headers + content_length = None + for line in headers.split("\n"): + if line.startswith("Content-Length:"): + content_length = int(line.split(":")[1].strip()) + LOGGER.info(f"Content-Length: {content_length}") + break + + if content_length is None: + LOGGER.error("No Content-Length header found") + return + + # Read the JSON body + try: + body_bytes = await reader.readexactly(content_length) + message_str = body_bytes.decode("utf-8", errors="ignore") + LOGGER.info(f"HTTP body ({content_length} bytes): {message_str}") + + # Parse and handle the message + try: + message_data = json.loads(message_str) + # Convert string type to enum + if "type" in message_data and isinstance( + message_data["type"], str + ): + message_data["type"] = DAPMessageType(message_data["type"]) + message = DAPMessage(**message_data) + LOGGER.info( + f"Parsed DAP message: type={message.type}, command={message.command}, seq={message.seq}" + ) + + # Call all message handlers + for handler in self.message_handlers: + try: + await handler(message) + except Exception as e: + LOGGER.error(f"Error in message handler: {e}") + except json.JSONDecodeError as e: + LOGGER.error(f"Failed to parse DAP message: {e}") + LOGGER.error(f"Raw message was: {message_str}") + # Try to read more data if the JSON is incomplete + try: + additional_data = await reader.read( + 1000 + ) # Read up to 1KB more + if additional_data: + extended_message = ( + message_str + + additional_data.decode("utf-8", errors="ignore") + ) + LOGGER.info( + f"Trying extended message: {extended_message}" + ) + try: + message_data = json.loads(extended_message) + # Convert string type to enum + if "type" in message_data and isinstance( + message_data["type"], str + ): + message_data["type"] = DAPMessageType( + message_data["type"] + ) + message = DAPMessage(**message_data) + LOGGER.info( + f"Successfully parsed extended DAP message: type={message.type}, command={message.command}, seq={message.seq}" + ) + + # Call all message handlers + for handler in self.message_handlers: + try: + await handler(message) + except Exception as e: + LOGGER.error( + f"Error in message handler: {e}" + ) + except json.JSONDecodeError as e2: + LOGGER.error( + f"Still failed to parse extended message: {e2}" + ) + except Exception as e: + LOGGER.error(f"Error reading additional data: {e}") + except Exception as e: + LOGGER.error(f"Error reading HTTP body: {e}") + except Exception as e: + LOGGER.error(f"Error reading HTTP body: {e}") + + async def _handle_json_messages( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + addr, + initial_bytes: bytes, + ) -> None: + """Handle JSON DAP messages.""" + LOGGER.info(f"Starting JSON message handling for {addr}") + + # Complete the initial JSON message + message_str = initial_bytes.decode("utf-8", errors="ignore") + LOGGER.info(f"Initial JSON message: {message_str}") + + # Parse and handle the message + try: + message_data = json.loads(message_str) + # Convert string type to enum + if "type" in message_data and isinstance( + message_data["type"], str + ): + message_data["type"] = DAPMessageType(message_data["type"]) + message = DAPMessage(**message_data) + LOGGER.info( + f"Parsed DAP message: type={message.type}, command={message.command}, seq={message.seq}" + ) + + # Call all message handlers + for handler in self.message_handlers: + try: + await handler(message) + except Exception as e: + LOGGER.error(f"Error in message handler: {e}") + except Exception as e: + LOGGER.error(f"Failed to parse DAP message: {e}") + + async def _handle_length_prefixed_messages( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + addr, + initial_bytes: bytes, + ) -> None: + """Handle length-prefixed DAP messages.""" + LOGGER.info(f"Starting length-prefixed message handling for {addr}") + + # Try to parse the initial bytes as length-prefixed + try: + # We already read 20 bytes, so we need to get the length from the first 4 + length_bytes = initial_bytes[:4] + message_length = int.from_bytes(length_bytes, "big") + LOGGER.info(f"Message length: {message_length} bytes") + + if message_length > 1000000: # 1MB limit + LOGGER.error( + f"Message too large ({message_length} bytes), likely malformed" + ) + return + + # Read the rest of the message + remaining_length = message_length - (len(initial_bytes) - 4) + if remaining_length > 0: + remaining_bytes = await reader.readexactly(remaining_length) + message_bytes = initial_bytes[4:] + remaining_bytes + else: + message_bytes = initial_bytes[4 : 4 + message_length] + + message_str = message_bytes.decode("utf-8", errors="ignore") + LOGGER.info(f"Raw DAP message: {message_str}") + + # Parse and handle the message + try: + message_data = json.loads(message_str) + # Convert string type to enum + if "type" in message_data and isinstance( + message_data["type"], str + ): + message_data["type"] = DAPMessageType(message_data["type"]) + message = DAPMessage(**message_data) + LOGGER.info( + f"Parsed DAP message: type={message.type}, command={message.command}, seq={message.seq}" + ) + + # Call all message handlers + for handler in self.message_handlers: + try: + await handler(message) + except Exception as e: + LOGGER.error(f"Error in message handler: {e}") + except Exception as e: + LOGGER.error(f"Failed to parse DAP message: {e}") + + except Exception as e: + LOGGER.error(f"Error handling length-prefixed message: {e}") + + +class DAPServer: + def __init__(self, session_manager): + self.session_manager = session_manager + self.transport = TCPDAPTransport() + self.running = False + self.debug_sessions: Dict[str, DebugSession] = {} + self.message_seq = 0 + LOGGER.info("DAP server initialized") + + async def start(self, host: str = "localhost", port: int = 5678) -> int: + """Start the DAP server.""" + LOGGER.info(f"Starting DAP server on {host}:{port}") + + if self.running: + LOGGER.info("DAP server already running") + return ( + self.transport.server.sockets[0].getsockname()[1] + if self.transport.server + else port + ) + + try: + # Register message handler + self.transport.add_message_handler(self._handle_message) + LOGGER.info("Message handler registered") + + # Start the transport + actual_port = await self.transport.start(host, port) + self.running = True + LOGGER.info( + f"DAP server started successfully on {host}:{actual_port}" + ) + return actual_port + except Exception as e: + LOGGER.error(f"Failed to start DAP server: {e}") + raise + + async def stop(self) -> None: + """Stop the DAP server.""" + if not self.running: + return + + LOGGER.info("Stopping DAP server") + self.running = False + await self.transport.stop() + LOGGER.info("DAP server stopped") + + async def _handle_message(self, message: DAPMessage) -> None: + """Handle incoming DAP messages.""" + LOGGER.info( + f"Processing DAP message: type={message.type}, command={message.command}, seq={message.seq}" + ) + + if message.type == DAPMessageType.REQUEST: + await self._handle_request(message) + else: + LOGGER.warning(f"Unhandled message type: {message.type}") + + async def _handle_request(self, message: DAPMessage) -> None: + """Handle DAP requests.""" + command = message.command + if not command: + LOGGER.warning("Received request with no command") + return + + LOGGER.info(f"Handling DAP request: {command} (seq={message.seq})") + if message.arguments: + LOGGER.debug(f"Request arguments: {message.arguments}") + + try: + if command == DAPRequestType.INITIALIZE.value: + await self._handle_initialize(message) + elif command == DAPRequestType.ATTACH.value: + await self._handle_attach(message) + elif command == DAPRequestType.SET_BREAKPOINTS.value: + await self._handle_set_breakpoints(message) + elif command == DAPRequestType.CONTINUE.value: + await self._handle_continue(message) + elif command == DAPRequestType.STACK_TRACE.value: + await self._handle_stack_trace(message) + elif command == DAPRequestType.VARIABLES.value: + await self._handle_variables(message) + elif command == DAPRequestType.EVALUATE.value: + await self._handle_evaluate(message) + elif command == DAPRequestType.THREADS.value: + await self._handle_threads(message) + elif command == DAPRequestType.CONFIGURATION_DONE.value: + await self._handle_configuration_done(message) + elif command == DAPRequestType.LAUNCH.value: + await self._handle_launch(message) + elif command == DAPRequestType.DISCONNECT.value: + await self._handle_disconnect(message) + elif command == DAPRequestType.PAUSE.value: + await self._handle_pause(message) + elif command == DAPRequestType.STEP_IN.value: + await self._handle_step_in(message) + elif command == DAPRequestType.STEP_OUT.value: + await self._handle_step_out(message) + elif command == DAPRequestType.STEP_OVER.value: + await self._handle_step_over(message) + elif command == DAPRequestType.SOURCES.value: + await self._handle_sources(message) + elif command == DAPRequestType.SCOPES.value: + await self._handle_scopes(message) + elif command == DAPRequestType.EXCEPTION_INFO.value: + await self._handle_exception_info(message) + else: + LOGGER.warning(f"Unhandled DAP command: {command}") + await self._send_error_response( + message, f"Unknown command: {command}" + ) + except Exception as e: + LOGGER.error(f"Error handling DAP request {command}: {e}") + await self._send_error_response(message, str(e)) + + async def _handle_initialize(self, message: DAPMessage) -> None: + """Handle initialize request.""" + LOGGER.info("Handling initialize request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + body={ + "supportsConfigurationDoneRequest": True, + "supportsEvaluateForHovers": True, + "supportsSetVariable": True, + "supportsConditionalBreakpoints": True, + "supportsHitConditionalBreakpoints": True, + "supportsLogPoints": True, + "supportsExceptionInfoRequest": True, + "supportsExceptionOptions": True, + "supportsValueFormattingOptions": True, + "supportsExceptionFilterOptions": True, + "supportsStepBack": False, + "supportsSetExpression": True, + "supportsModulesRequest": True, + "additionalModuleColumns": [], + "supportedChecksumAlgorithms": [], + "supportsRestartRequest": True, + "supportsGotoTargetsRequest": True, + "supportsStepInTargetsRequest": True, + "supportsCompletionsRequest": True, + "completionTriggerCharacters": [".", "["], + "supportsModulesRequest": True, + "supportsRestartFrame": True, + "supportsStepInTargetsRequest": True, + "supportsDelayedStackTraceLoading": True, + "supportsLoadedSourcesRequest": True, + "supportsLogPoints": True, + "supportsTerminateThreadsRequest": True, + "supportsSetExpression": True, + "supportsTerminateRequest": True, + "supportsDataBreakpoints": True, + "supportsReadMemoryRequest": True, + "supportsWriteMemoryRequest": True, + "supportsDisassembleRequest": True, + "supportsCancelRequest": True, + "supportsBreakpointLocationsRequest": True, + "supportsClipboardContext": True, + "supportsSteppingGranularity": True, + "supportsInstructionBreakpoints": True, + "supportsExceptionFilterOptions": True, + "supportsSingleThreadExecutionRequests": True, + }, + ) + await self.transport.send_message(response) + + async def _handle_attach(self, message: DAPMessage) -> None: + """Handle attach request.""" + LOGGER.info("Handling attach request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + ) + await self.transport.send_message(response) + + async def _handle_set_breakpoints(self, message: DAPMessage) -> None: + """Handle set breakpoints request.""" + LOGGER.info("Handling set breakpoints request") + args = message.arguments or {} + source = args.get("source", {}) + path = source.get("path", "") + breakpoints = args.get("breakpoints", []) + + # Store breakpoints for this file + session = self._get_default_session() + if session: + session.breakpoints[path] = [] + for bp in breakpoints: + line = bp.get("line", 0) + session.breakpoints[path].append(Breakpoint(line=line)) + + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + body={ + "breakpoints": [ + {"id": i, "verified": True, "line": bp.get("line", 0)} + for i, bp in enumerate(breakpoints) + ] + }, + ) + await self.transport.send_message(response) + + async def _handle_continue(self, message: DAPMessage) -> None: + """Handle continue request.""" + LOGGER.info("Handling continue request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + body={"allThreadsContinued": True}, + ) + await self.transport.send_message(response) + + async def _handle_stack_trace(self, message: DAPMessage) -> None: + """Handle stack trace request.""" + LOGGER.info("Handling stack trace request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + body={ + "stackFrames": [ + { + "id": 1, + "name": "main", + "line": 1, + "column": 1, + "source": { + "name": "main.py", + "path": "/path/to/main.py", + }, + } + ], + "totalFrames": 1, + }, + ) + await self.transport.send_message(response) + + async def _handle_variables(self, message: DAPMessage) -> None: + """Handle variables request.""" + LOGGER.info("Handling variables request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + body={"variables": []}, + ) + await self.transport.send_message(response) + + async def _handle_evaluate(self, message: DAPMessage) -> None: + """Handle evaluate request.""" + LOGGER.info("Handling evaluate request") + args = message.arguments or {} + expression = args.get("expression", "") + + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + body={ + "result": f"Evaluated: {expression}", + "type": "string", + "variablesReference": 0, + }, + ) + await self.transport.send_message(response) + + async def _handle_threads(self, message: DAPMessage) -> None: + """Handle threads request.""" + LOGGER.info("Handling threads request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + body={"threads": [{"id": 1, "name": "MainThread"}]}, + ) + await self.transport.send_message(response) + + async def _handle_configuration_done(self, message: DAPMessage) -> None: + """Handle configurationDone request.""" + LOGGER.info("Handling configurationDone request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + ) + await self.transport.send_message(response) + + async def _handle_launch(self, message: DAPMessage) -> None: + """Handle launch request.""" + LOGGER.info("Handling launch request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + ) + await self.transport.send_message(response) + + async def _handle_disconnect(self, message: DAPMessage) -> None: + """Handle disconnect request.""" + LOGGER.info("Handling disconnect request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + ) + await self.transport.send_message(response) + + async def _handle_pause(self, message: DAPMessage) -> None: + """Handle pause request.""" + LOGGER.info("Handling pause request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + ) + await self.transport.send_message(response) + + async def _handle_step_in(self, message: DAPMessage) -> None: + """Handle stepIn request.""" + LOGGER.info("Handling stepIn request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + ) + await self.transport.send_message(response) + + async def _handle_step_out(self, message: DAPMessage) -> None: + """Handle stepOut request.""" + LOGGER.info("Handling stepOut request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + ) + await self.transport.send_message(response) + + async def _handle_step_over(self, message: DAPMessage) -> None: + """Handle stepOver request.""" + LOGGER.info("Handling stepOver request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + ) + await self.transport.send_message(response) + + async def _handle_sources(self, message: DAPMessage) -> None: + """Handle sources request.""" + LOGGER.info("Handling sources request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + body={ + "sources": [ + { + "name": "main.py", + "path": "/path/to/main.py", + "sourceReference": 0, + } + ] + }, + ) + await self.transport.send_message(response) + + async def _handle_scopes(self, message: DAPMessage) -> None: + """Handle scopes request.""" + LOGGER.info("Handling scopes request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + body={ + "scopes": [ + { + "name": "Local", + "variablesReference": 0, + "expensive": False, + } + ] + }, + ) + await self.transport.send_message(response) + + async def _handle_exception_info(self, message: DAPMessage) -> None: + """Handle exceptionInfo request.""" + LOGGER.info("Handling exceptionInfo request") + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=message.seq, + success=True, + command=message.command, + body={ + "exceptionId": "unhandled", + "description": "An unhandled exception occurred.", + "breakMode": "always", + }, + ) + await self.transport.send_message(response) + + async def _send_error_response( + self, original_message: DAPMessage, error_message: str + ) -> None: + """Send an error response.""" + response = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.RESPONSE, + request_seq=original_message.seq, + success=False, + command=original_message.command, + message=error_message, + ) + await self.transport.send_message(response) + + def _next_seq(self) -> int: + """Get the next sequence number.""" + self.message_seq += 1 + return self.message_seq + + def _get_default_session(self) -> Optional[DebugSession]: + """Get the default debug session.""" + if not self.debug_sessions: + session_id = "default" + self.debug_sessions[session_id] = DebugSession( + session_id=session_id + ) + return self.debug_sessions.get("default") + + def _get_debug_session(self, session_id: str) -> Optional[DebugSession]: + """Get a debug session by ID.""" + return self.debug_sessions.get(session_id) + + def _path_to_cell_id(self, path: str) -> Optional[str]: + """Convert a file path to a cell ID.""" + # This is a placeholder - in a real implementation, + # you'd map file paths to cell IDs + return None + + def install_breakpoint_hook(self) -> None: + """Install a breakpoint hook for PDB integration.""" + # This is a placeholder for PDB integration + pass + + async def _send_stopped_event(self, reason: str = "breakpoint") -> None: + """Send a stopped event.""" + event = DAPMessage( + seq=self._next_seq(), + type=DAPMessageType.EVENT, + event=DAPEventType.STOPPED.value, + body={"reason": reason, "threadId": 1, "allThreadsStopped": True}, + ) + await self.transport.send_message(event) + + +# Global DAP server instance +_dap_server: Optional[DAPServer] = None + + +def get_dap_server(session_manager) -> DAPServer: + """Get the global DAP server instance.""" + global _dap_server + if _dap_server is None: + LOGGER.info("Creating new DAP server instance") + _dap_server = DAPServer(session_manager) + return _dap_server diff --git a/marimo/_server/main.py b/marimo/_server/main.py index 2f472421c13..aae81650f9e 100644 --- a/marimo/_server/main.py +++ b/marimo/_server/main.py @@ -55,6 +55,7 @@ def create_starlette_app( allow_origins: Optional[tuple[str, ...]] = None, lsp_servers: Optional[list[LspServer]] = None, skew_protection: bool = True, + debug_port: Optional[int] = None, ) -> Starlette: final_middlewares: list[Middleware] = [] @@ -100,12 +101,18 @@ def create_starlette_app( _create_lsps_proxy_middleware(servers=lsp_servers) ) + # Add debug server proxy middleware if debug port is available + if debug_port is not None: + final_middlewares.append( + _create_debug_proxy_middleware(debug_port=debug_port) + ) + if middleware: final_middlewares.extend(middleware) final_middlewares.extend(MIDDLEWARE_REGISTRY.get_all()) - return Starlette( + app = Starlette( routes=build_routes(base_url=base_url), middleware=final_middlewares, lifespan=lifespan, @@ -117,6 +124,15 @@ def create_starlette_app( }, ) + # Add debug server proxy middleware after app creation if debug_port is set in app state + if hasattr(app.state, "debug_port") and app.state.debug_port is not None: + debug_middleware = _create_debug_proxy_middleware( + debug_port=app.state.debug_port + ) + app.add_middleware(debug_middleware.cls, **debug_middleware.options) + + return app + def _create_mpl_proxy_middleware(base_url: str) -> Middleware: # Construct the full proxy path with base_url diff --git a/marimo/_server/start.py b/marimo/_server/start.py index 8f6cace71d3..83f3a00cc81 100644 --- a/marimo/_server/start.py +++ b/marimo/_server/start.py @@ -162,11 +162,13 @@ def start( log_level = "info" if development_mode else "error" (external_port, external_host) = _resolve_proxy(port, host, proxy) + app = create_starlette_app( base_url=base_url, host=external_host, lifespan=Lifespans( [ + lifespans.debug_server, # Add debug server lifespan lifespans.lsp, lifespans.mcp, lifespans.etc, @@ -182,6 +184,7 @@ def start( if lsp_composite_server is not None else None, skew_protection=skew_protection, + debug_port=None, # Debug port will be set by lifespan ) app.state.port = external_port diff --git a/working_sample.py b/working_sample.py new file mode 100644 index 00000000000..2693c3da81d --- /dev/null +++ b/working_sample.py @@ -0,0 +1,214 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "duckdb==1.1.1", +# "marimo", +# "numpy==2.2.6", +# "pandas==2.2.3", +# "polars==1.18.0", +# "pyarrow==18.1.0", +# "sqlglot==26.19.0", +# ] +# /// + +import marimo + +__generated_with = "0.14.16" +app = marimo.App(width="full") + + +@app.cell +def _(): + import os + + import duckdb + + import marimo as mo + + return duckdb, mo, os + + +@app.cell +def _(): + print(1) + from marimo._server.debug.dap_server import get_dap_server + + print("DAP server module imports successfully") + print() + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md( + r""" + # Database explorer + + This notebook lets you explore the contents of a database. Start by providing a database URL. + """ + ) + return + + +@app.cell +def _(mo, os): + database_url = mo.ui.text( + label="Database URL", + full_width=True, + value=os.environ.get("DATABASE_URL", ""), + ) + database_url + return (database_url,) + + +@app.cell +def _(database_url, duckdb): + if database_url.value: + duckdb.sql( + f""" + INSTALL postgres; + LOAD postgres; + + DETACH DATABASE IF EXISTS my_db; + ATTACH DATABASE '{database_url.value}' AS my_db (TYPE postgres, READ_ONLY); + """ + ) + return + + +@app.cell +def _(duckdb): + duckdb.sql("SHOW DATABASES").show() + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r"""## Tables""") + return + + +@app.cell +def _(mo): + _df = mo.sql( + """ + SHOW ALL TABLES; + """ + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r"""## Other meta table functions""") + return + + +@app.cell +def _(): + FUNCTIONS = [ + "duckdb_columns()", # columns + "duckdb_constraints()", # constraints + "duckdb_databases()", # lists the databases that are accessible from within the current DuckDB process + "duckdb_dependencies()", # dependencies between objects + "duckdb_extensions()", # extensions + "duckdb_functions()", # functions + "duckdb_indexes()", # secondary indexes + "duckdb_keywords()", # DuckDB's keywords and reserved words + "duckdb_optimizers()", # the available optimization rules in the DuckDB instance + "duckdb_schemas()", # schemas + "duckdb_sequences()", # sequences + "duckdb_settings()", # settings + "duckdb_tables()", # base tables + "duckdb_types()", # data types + "duckdb_views()", # views + "duckdb_temporary_files()", # the temporary files DuckDB has written to disk, to offload data from memory + ] + return (FUNCTIONS,) + + +@app.cell +def _(FUNCTIONS, mo): + function = mo.ui.dropdown( + label="Dropdown", + options=FUNCTIONS, + value=FUNCTIONS[0], + ) + function + return (function,) + + +@app.cell +def _(function, mo): + _df = mo.sql( + f""" + SELECT * FROM {function.value} WHERE database_name == 'my_db' + """ + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r"""## Interact with your tables""") + return + + +@app.cell +def _(duckdb, mo): + tables = duckdb.execute( + """ + SELECT table_name FROM duckdb_tables() WHERE internal = False; + """ + ).df() + table_names = list(tables["table_name"]) + mo.accordion({f"Found {len(table_names)} tables": table_names}) + return (table_names,) + + +@app.cell +def _(mo, table_names): + mo.stop(not table_names) + table_select = mo.ui.dropdown( + label="Table", + options=table_names, + ) + limit = mo.ui.slider( + label="Limit", + start=100, + stop=10_000, + step=10, + debounce=True, + show_value=True, + ) + mo.hstack([table_select, limit]).left() + return limit, table_select + + +@app.cell +def _(mo, table_select): + mo.stop(not table_select.value) + table_select_value = table_select.value + return (table_select_value,) + + +@app.cell +def _(limit, mo, null, table_select_value): + selected_table = mo.sql( + f""" + select * from my_db.{table_select_value} LIMIT {limit.value}; + """ + ) + return (selected_table,) + + +@app.cell +def _(mo, selected_table): + mo.ui.data_explorer(selected_table) + return + + +if __name__ == "__main__": + from marimo._cli.cli import edit + + edit(args=(__file__,))