diff --git a/pyproject.toml b/pyproject.toml index cd5f2787d..052eb2fc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,13 +14,11 @@ exclude = ''' ''' [tool.pyright] -pythonVersion = "3.8" -include = ["src/**", "tests/**" ] -extraPaths = ["src/debugpy/_vendored/pydevd"] +pythonVersion = "3.12" +include = ["src/**", "tests/**"] +extraPaths = ["src", "src/debugpy/_vendored/pydevd", "tests"] ignore = ["src/debugpy/_vendored/pydevd", "src/debugpy/_version.py"] -executionEnvironments = [ - { root = "src" }, { root = "." } -] +executionEnvironments = [{ root = "." }] [tool.ruff] # Enable the pycodestyle (`E`) and Pyflakes (`F`) rules by default. @@ -28,10 +26,19 @@ executionEnvironments = [ # McCabe complexity (`C901`) by default. select = ["E", "F"] ignore = [ - "E203", "E221", "E222", "E226", "E261", "E262", "E265", "E266", - "E401", "E402", - "E501", - "E722", "E731" + "E203", + "E221", + "E222", + "E226", + "E261", + "E262", + "E265", + "E266", + "E401", + "E402", + "E501", + "E722", + "E731", ] # Allow autofix for all enabled rules (when `--fix`) is provided. @@ -40,29 +47,29 @@ unfixable = [] # Exclude a variety of commonly ignored directories. exclude = [ - ".bzr", - ".direnv", - ".eggs", - ".git", - ".git-rewrite", - ".hg", - ".mypy_cache", - ".nox", - ".pants.d", - ".pytype", - ".ruff_cache", - ".svn", - ".tox", - ".venv", - "__pypackages__", - "_build", - "buck-out", - "build", - "dist", - "node_modules", - "venv", - "versioneer.py", - "src/debugpy/_vendored/pydevd" + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", + "versioneer.py", + "src/debugpy/_vendored/pydevd", ] per-file-ignores = {} @@ -73,4 +80,4 @@ line-length = 88 dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" # Assume Python 3.8 -target-version = "py38" \ No newline at end of file +target-version = "py38" diff --git a/src/debugpy/adapter/__init__.py b/src/debugpy/adapter/__init__.py index fa55b2597..fb303477c 100644 --- a/src/debugpy/adapter/__init__.py +++ b/src/debugpy/adapter/__init__.py @@ -8,7 +8,67 @@ if typing.TYPE_CHECKING: __all__: list[str] -__all__ = [] +__all__ = ["CAPABILITIES", "access_token"] + +EXCEPTION_FILTERS = [ + { + "filter": "raised", + "label": "Raised Exceptions", + "default": False, + "description": "Break whenever any exception is raised.", + }, + { + "filter": "uncaught", + "label": "Uncaught Exceptions", + "default": True, + "description": "Break when the process is exiting due to unhandled exception.", + }, + { + "filter": "userUnhandled", + "label": "User Uncaught Exceptions", + "default": False, + "description": "Break when exception escapes into library code.", + }, +] + +CAPABILITIES_V1 = { + "supportsCompletionsRequest": True, + "supportsConditionalBreakpoints": True, + "supportsConfigurationDoneRequest": True, + "supportsDebuggerProperties": True, + "supportsDelayedStackTraceLoading": True, + "supportsEvaluateForHovers": True, + "supportsExceptionInfoRequest": True, + "supportsExceptionOptions": True, + "supportsFunctionBreakpoints": True, + "supportsHitConditionalBreakpoints": True, + "supportsLogPoints": True, + "supportsModulesRequest": True, + "supportsSetExpression": True, + "supportsSetVariable": True, + "supportsValueFormattingOptions": True, + "supportsTerminateRequest": True, + "supportsGotoTargetsRequest": True, + "supportsClipboardContext": True, + "exceptionBreakpointFilters": EXCEPTION_FILTERS, + "supportsStepInTargetsRequest": True, +} + +CAPABILITIES_V2 = { + "supportsConfigurationDoneRequest": True, + "supportsConditionalBreakpoints": True, + "supportsHitConditionalBreakpoints": True, + "supportsEvaluateForHovers": True, + "exceptionBreakpointFilters": EXCEPTION_FILTERS, + "supportsSetVariable": True, + "supportsExceptionInfoRequest": True, + "supportsDelayedStackTraceLoading": True, + "supportsLogPoints": True, + "supportsSetExpression": True, + "supportsTerminateRequest": True, + "supportsClipboardContext": True, + "supportsGotoTargetsRequest": True, +} access_token = None """Access token used to authenticate with this adapter.""" diff --git a/src/debugpy/adapter/__main__.py b/src/debugpy/adapter/__main__.py index e18ecd560..0630c38fc 100644 --- a/src/debugpy/adapter/__main__.py +++ b/src/debugpy/adapter/__main__.py @@ -42,7 +42,7 @@ def main(args): stdio.close() if args.log_stderr: - log.stderr.levels |= set(log.LEVELS) + log.stderr.levels |= {"info", "warning", "error"} if args.log_dir is not None: log.log_dir = args.log_dir diff --git a/src/debugpy/adapter/clients.py b/src/debugpy/adapter/clients.py index ee1d15145..f9ac35864 100644 --- a/src/debugpy/adapter/clients.py +++ b/src/debugpy/adapter/clients.py @@ -25,10 +25,14 @@ class Client(components.Component): class Capabilities(components.Capabilities): PROPERTIES = { + # defaults for optional properties in DAP "initialize request" "supportsVariableType": False, "supportsVariablePaging": False, "supportsRunInTerminalRequest": False, "supportsMemoryReferences": False, + "supportsProgressReporting": False, + "supportsInvalidatedEvent": False, + "supportsMemoryEvent": False, "supportsArgsCanBeInterpretedByShell": False, "supportsStartDebuggingRequest": False, } @@ -148,55 +152,12 @@ def request(self, request): def initialize_request(self, request): if self._initialize_request is not None: raise request.isnt_valid("Session is already initialized") - + self.client_id = request("clientID", "") self.capabilities = self.Capabilities(self, request) self.expectations = self.Expectations(self, request) self._initialize_request = request - - exception_breakpoint_filters = [ - { - "filter": "raised", - "label": "Raised Exceptions", - "default": False, - "description": "Break whenever any exception is raised.", - }, - { - "filter": "uncaught", - "label": "Uncaught Exceptions", - "default": True, - "description": "Break when the process is exiting due to unhandled exception.", - }, - { - "filter": "userUnhandled", - "label": "User Uncaught Exceptions", - "default": False, - "description": "Break when exception escapes into library code.", - }, - ] - - return { - "supportsCompletionsRequest": True, - "supportsConditionalBreakpoints": True, - "supportsConfigurationDoneRequest": True, - "supportsDebuggerProperties": True, - "supportsDelayedStackTraceLoading": True, - "supportsEvaluateForHovers": True, - "supportsExceptionInfoRequest": True, - "supportsExceptionOptions": True, - "supportsFunctionBreakpoints": True, - "supportsHitConditionalBreakpoints": True, - "supportsLogPoints": True, - "supportsModulesRequest": True, - "supportsSetExpression": True, - "supportsSetVariable": True, - "supportsValueFormattingOptions": True, - "supportsTerminateRequest": True, - "supportsGotoTargetsRequest": True, - "supportsClipboardContext": True, - "exceptionBreakpointFilters": exception_breakpoint_filters, - "supportsStepInTargetsRequest": True, - } + return adapter.CAPABILITIES_V2 # Common code for "launch" and "attach" request handlers. # diff --git a/src/debugpy/common/log.py b/src/debugpy/common/log.py index 099e93c71..4f5e581a2 100644 --- a/src/debugpy/common/log.py +++ b/src/debugpy/common/log.py @@ -223,7 +223,7 @@ def reraise_exception(format_string="", *args, **kwargs): raise -def to_file(filename=None, prefix=None, levels=LEVELS): +def to_file(filename=None, prefix=None, levels=("error", "warning", "info")): """Starts logging all messages at the specified levels to the designated file. Either filename or prefix must be specified, but not both. diff --git a/src/debugpy/common/messaging.py b/src/debugpy/common/messaging.py index b133f71b6..ae66bcbcd 100644 --- a/src/debugpy/common/messaging.py +++ b/src/debugpy/common/messaging.py @@ -140,7 +140,7 @@ def close(self): return self._closed = True - log.debug("Closing {0} message stream", self.name) + log.info("Closing {0} message stream", self.name) try: try: # Close the writer first, so that the other end of the connection has @@ -158,7 +158,7 @@ def close(self): except Exception: # pragma: no cover log.reraise_exception("Error while closing {0} message stream", self.name) - def _log_message(self, dir, data, logger=log.debug): + def _log_message(self, dir, data, logger=log.info): return logger("{0} {1} {2}", self.name, dir, data) def _read_line(self, reader): @@ -372,7 +372,7 @@ def __call__(self, key, validate, optional=False): See debugpy.common.json for reusable validators. """ - if not validate: + if validate is None: validate = lambda x: x elif isinstance(validate, type) or isinstance(validate, tuple): validate = json.of_type(validate, optional=optional) @@ -1163,13 +1163,13 @@ def wait(self): if parser_thread is not None: parser_thread.join() except AssertionError: - log.debug("Handled error joining parser thread.") + log.info("Handled error joining parser thread.") try: handler_thread = self._handler_thread if handler_thread is not None: handler_thread.join() except AssertionError: - log.debug("Handled error joining handler thread.") + log.info("Handled error joining handler thread.") # Order of keys for _prettify() - follows the order of properties in # https://microsoft.github.io/debug-adapter-protocol/specification @@ -1289,13 +1289,13 @@ def delegate(self, message): exc.propagate(message) def _parse_incoming_messages(self): - log.debug("Starting message loop for channel {0}", self) + log.info("Starting message loop for channel {0}", self) try: while True: self._parse_incoming_message() except NoMoreMessages as exc: - log.debug("Exiting message loop for channel {0}: {1}", self, exc) + log.info("Exiting message loop for channel {0}: {1}", self, exc) with self: # Generate dummy responses for all outstanding requests. err_message = str(exc) @@ -1473,14 +1473,17 @@ def _get_handler_for(self, type, name): except AttributeError: continue - raise AttributeError( - "handler object {0} for channel {1} has no handler for {2} {3!r}".format( - util.srcnameof(handlers), - self, - type, - name, + try: + raise AttributeError( + "handler object {0} for channel {1} has no handler for {2} {3!r}".format( + util.srcnameof(handlers), + self, + type, + name, + ) ) - ) + except Exception: + log.reraise_exception() def _handle_disconnect(self): handler = getattr(self.handlers, "disconnect", lambda: None) diff --git a/src/debugpy/common/util.py b/src/debugpy/common/util.py index 54850a07b..7bf22c37f 100644 --- a/src/debugpy/common/util.py +++ b/src/debugpy/common/util.py @@ -2,9 +2,12 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. +from functools import partial import inspect +import itertools import os import sys +from types import CodeType def evaluate(code, path=__file__, mode="eval"): @@ -160,5 +163,32 @@ def hide_thread_from_debugger(thread): DEBUGPY_TRACE_DEBUGPY is used to debug debugpy with debugpy """ if hide_debugpy_internals(): + thread.is_debugpy_thread = True thread.pydev_do_not_trace = True thread.is_pydev_daemon_thread = True + + +class IDMap(object): + + def __init__(self, id_generator=partial(next, itertools.count(0))): + self._value_to_key = {} + self._key_to_value = {} + self._next_id = id_generator + + def obtain_value(self, key): + return self._key_to_value[key] + + def obtain_key(self, value): + try: + key = self._value_to_key[value] + except KeyError: + key = self._next_id() + self._key_to_value[key] = value + self._value_to_key[value] = key + return key + + +def contains_line(code: CodeType, line: int) -> bool: + return any( + (len(co_line) == 3 and co_line[2] == line for co_line in code.co_lines()) + ) diff --git a/src/debugpy/server/__init__.py b/src/debugpy/server/__init__.py index 42d5367f0..6c0c31649 100644 --- a/src/debugpy/server/__init__.py +++ b/src/debugpy/server/__init__.py @@ -2,6 +2,29 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -# "force_pydevd" must be imported first to ensure (via side effects) -# that the debugpy-vendored copy of pydevd gets used. -import debugpy._vendored.force_pydevd # noqa +import itertools +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + import debugpy.server.adapters as adapters + +# Unique IDs for DAP objects such as threads, variables, breakpoints etc. These are +# negative to allow for pre-existing OS-assigned IDs (which are positive) to be used +# where available, e.g. for threads. +_dap_ids = itertools.count(-1, -1) + + +def new_dap_id() -> int: + """Returns the next unique ID.""" + return next(_dap_ids) + + +def adapter() -> Optional["adapters.Adapter"]: + """ + Returns the instance of Adapter corresponding to the debug adapter that is currently + connected to this process, or None if there is no adapter connected. Use in lieu of + Adapter.instance to avoid import cycles. + """ + from debugpy.server.adapters import Adapter + + return Adapter.instance diff --git a/src/debugpy/server/adapters.py b/src/debugpy/server/adapters.py new file mode 100644 index 000000000..fe00fd512 --- /dev/null +++ b/src/debugpy/server/adapters.py @@ -0,0 +1,549 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +import inspect +import os +import sys +import threading +from itertools import islice + +from debugpy import adapter +from debugpy.adapter import components +from debugpy.common import json, log, messaging, sockets +from debugpy.common.messaging import MessageDict, Request +from debugpy.common.util import IDMap +from debugpy.server import eval, new_dap_id +from debugpy.server.tracing import ( + Breakpoint, + Condition, + ExceptionBreakMode, + HitCondition, + LogMessage, + Source, + StackFrame, + Thread, + tracer, +) + + +class Adapter: + """Represents the debug adapter connected to this debug server.""" + + class Capabilities(components.Capabilities): + PROPERTIES = {} + + class Expectations(components.Capabilities): + PROPERTIES = { + "locale": "en-US", + "linesStartAt1": True, + "columnsStartAt1": True, + "pathFormat": json.enum("path", optional=True), # we don't support "uri" + } + + instance = None + """If debug adapter is connected, the Adapter instance for it; otherwise, None.""" + + connected_event = threading.Event() + """Event that is only set while the debug adapter is connected to this server.""" + + channel = None + """DAP message channel to the adapter.""" + + adapter_access_token = None + """Access token that this server must use to authenticate with the adapter.""" + + server_access_token = None + """Access token that the adapter must use to authenticate with this server.""" + + _is_initialized: bool = False + _has_started: bool = False + _client_id: str = None + _capabilities: Capabilities = None + _expectations: Expectations = None + _start_request: messaging.Request = None + _goto_targets_map: IDMap = IDMap(new_dap_id) + + def __init__(self, stream: messaging.JsonIOStream): + self._is_initialized = False + self._has_started = False + self._client_id = None + self._capabilities = None + self._expectations = None + self._start_request = None + self._tracer = tracer + + self.channel = messaging.JsonMessageChannel(stream, self) + self.channel.start() + + @classmethod + def connect(self, host, port) -> "Adapter": + assert self.instance is None + log.info("Connecting to adapter at {0}:{1}", host, port) + sock = sockets.create_client() + sock.connect((host, port)) + stream = messaging.JsonIOStream.from_socket(sock, "Adapter") + self.instance = Adapter(stream) + return self.instance + + def pydevdAuthorize_request(self, request: Request): + if self.server_access_token is not None: + server_token = request("debugServerAccessToken", str, optional=True) + if server_token != self.server_access_token: + raise request.cant_handle("Invalid access token") + return { + "clientAccessToken": self.adapter_access_token, + } + + def pydevdSystemInfo_request(self, request: Request): + return { + "process": { + "bitness": sys.maxsize.bit_length(), + "executable": sys.executable, + "pid": os.getpid(), + "ppid": os.getppid(), # FIXME Win32 venv stub + }, + "platform": { + "name": sys.platform, + }, + "python": { + "version": sys.version, + "version_info": sys.version_info, + "implementation": { + # TODO + }, + }, + } + + def initialize_request(self, request: Request): + if self._is_initialized: + raise request.isnt_valid("Session is already initialized") + + self._client_id = request("clientID", "") + self._capabilities = self.Capabilities(None, request) + self._expectations = self.Expectations(None, request) + self._is_initialized = True + return adapter.CAPABILITIES_V2 + + def _handle_start_request(self, request: Request): + if not self._is_initialized: + raise request.isnt_valid("Session is not initialized") + if self._start_request is not None: + raise request.isnt_valid("Session is already started") + + self._start_request = request + self.channel.send_event("initialized") + + # TODO: fix to comply with DAP spec. The adapter currently expects this + # non-standard behavior because pydevd does it that way, so the adapter + # needs to be fixed as well. + return {} + return messaging.NO_RESPONSE # will respond on "configurationDone" + + def launch_request(self, request: Request): + return self._handle_start_request(request) + + def attach_request(self, request: Request): + return self._handle_start_request(request) + + def configurationDone_request(self, request: Request): + if self._start_request is None or self._has_started: + raise request.cant_handle( + '"configurationDone" is only allowed during handling of a "launch" ' + 'or an "attach" request' + ) + + self._tracer.start() + self._has_started = True + + request.respond({}) + # _start_request.respond({}) + self.connected_event.set() + + self.channel.send_event( + "process", + { + "name": "Python Debuggee", # TODO + "startMethod": self._start_request.command, + "isLocalProcess": True, + "systemProcessId": os.getpid(), + "pointerSize": sys.maxsize.bit_length(), + }, + ) + + def setFunctionBreakpoints_request(self, request: Request): + # TODO + return Exception("Function breakpoints are not supported") + + def setExceptionBreakpoints_request(self, request: Request): + # TODO: "exceptionOptions" + + filters = set(request("filters", json.array(str))) + if len(filters - {"raised", "uncaught", "userUncaught"}): + raise request.isnt_valid( + f"Unsupported exception breakpoint filter: {filter!r}" + ) + + break_mode = ExceptionBreakMode.NEVER + if "raised" in filters: + break_mode = ExceptionBreakMode.ALWAYS + elif "uncaught" in filters: + break_mode = ExceptionBreakMode.UNHANDLED + elif "userUncaught" in filters: + break_mode = ExceptionBreakMode.USER_UNHANDLED + self._tracer.exception_break_mode = break_mode + + # TODO: return "breakpoints" + return {} + + def setBreakpoints_request(self, request: Request): + # TODO: implement source.reference for setting breakpoints in sources for + # which source code was decompiled or retrieved via inspect.getsource. + source = Source(request("source", json.object())("path", str)) + + # TODO: implement column support. + # Use dis.get_instruction() to iterate over instructions and corresponding + # dis.Positions to find the instruction to which the column corresponds, + # and use monitoring.events.INSTRUCTION rather than LINE. + # NOTE: needs perf testing to see if INSTRUCTION is too slow even when + # returning monitoring.DISABLE. Might need to pick LINE or INSTRUCTION based + # on what's requested. Would be nice to always use INSTRUCTION tho. + + if "breakpoints" in request.arguments: + bps = list(request("breakpoints", json.array(json.object()))) + else: + lines = request("lines", json.array(int)) + bps = [MessageDict(request, {"line": line}) for line in lines] + + Breakpoint.clear([source]) + + # Do the first pass validating conditions and log messages for syntax errors; if + # any breakpoint fails validation, we want to respond with an error right away + # so that user gets immediate feedback, but this also means that we shouldn't + # actually set any breakpoints until we've validated all of them. + bps_info = [] + for bp in bps: + id = new_dap_id() + line = bp("line", int) + + # A missing condition or log message can be represented as the corresponding + # property missing, or as the property being present but set to empty string. + + condition = bp("condition", str, optional=True) + if condition: + try: + condition = Condition(id, condition) + except SyntaxError as exc: + raise request.isnt_valid( + f"Syntax error in condition ({condition}): {exc}" + ) + else: + condition = None + + hit_condition = bp("hitCondition", str, optional=True) + if hit_condition: + try: + hit_condition = HitCondition(id, hit_condition) + except SyntaxError as exc: + raise request.isnt_valid( + f"Syntax error in hit condition ({hit_condition}): {exc}" + ) + else: + hit_condition = None + + log_message = bp("logMessage", str, optional=True) + if log_message: + try: + log_message = LogMessage(id, log_message) + except SyntaxError as exc: + raise request.isnt_valid( + f"Syntax error in log message f{log_message!r}: {exc}" + ) + else: + log_message = None + + bps_info.append((id, source, line, condition, hit_condition, log_message)) + + # Now that we know all breakpoints are syntactically valid, we can set them. + bps_set = [ + Breakpoint( + id, + source, + line, + condition=condition, + hit_condition=hit_condition, + log_message=log_message, + ) + for id, source, line, condition, hit_condition, log_message in bps_info + ] + return {"breakpoints": bps_set} + + def threads_request(self, request: Request): + return {"threads": Thread.enumerate()} + + def stackTrace_request(self, request: Request): + thread_id = request("threadId", int) + start_frame = request("startFrame", 0) + levels = request("levels", int, optional=True) + + thread = Thread.get(thread_id) + if thread is None: + raise request.isnt_valid(f'Unknown thread with "threadId":{thread_id}') + + try: + stack_trace = thread.get_stack_trace() + except ValueError: + raise request.isnt_valid(f"Thread {thread_id} is not suspended") + + stop_frame = ( + len(stack_trace) if levels == () or levels == 0 else start_frame + levels + ) + log.info(f"stackTrace info {start_frame} {stop_frame}") + frames = None + try: + frames = islice(stack_trace, start_frame, stop_frame) + return { + "stackFrames": list(frames), + "totalFrames": len(stack_trace), + } + finally: + del frames + + # For "pause" and "continue" requests, DAP requires a thread ID to be specified, + # but does not require the adapter to only pause/unpause the specified thread. + # Visual Studio debug adapter host does not support the ability to pause/unpause + # only the specified thread, and requires the adapter to always pause/unpause all + # threads. For "continue" requests, there is a capability flag that the client can + # use to indicate support for per-thread continuation, but there's no such flag + # for per-thread pausing. Furethermore, the semantics of unpausing a specific + # thread after all threads have been paused is unclear in the event the unpaused + # thread then spawns additional threads. Therefore, we always ignore the "threadId" + # property and just pause/unpause everything. + + def pause_request(self, request: Request): + try: + self._tracer.pause() + except ValueError: + raise request.cant_handle("No threads to pause") + return {} + + def continue_request(self, request: Request): + self._tracer.resume() + return {} + + def stepIn_request(self, request: Request): + # TODO: support "granularity" + thread_id = request("threadId", int) + thread = Thread.get(thread_id) + if thread is None: + raise request.isnt_valid(f'Unknown thread with "threadId":{thread_id}') + self._tracer.step_in(thread) + return {} + + def stepOut_request(self, request: Request): + # TODO: support "granularity" + thread_id = request("threadId", int) + thread = Thread.get(thread_id) + if thread is None: + raise request.isnt_valid(f'Unknown thread with "threadId":{thread_id}') + self._tracer.step_out(thread) + return {} + + def next_request(self, request: Request): + thread_id = request("threadId", int) + thread = Thread.get(thread_id) + if thread is None: + raise request.isnt_valid(f'Unknown thread with "threadId":{thread_id}') + self._tracer.step_over(thread) + return {} + + def gotoTargets_request(self, request: Request) -> dict: + source = request("source", json.object()) + path = source("path", str) + source = Source(path) + line = request("line", int) + target_id = self._goto_targets_map.obtain_key((source, line)) + target = {"id": target_id, "label": f"({path}:{line})", "line": line} + + return {"targets": [target]} + + def goto_request(self, request: Request) -> dict: + thread_id = request("threadId", int) + thread = Thread.get(thread_id) + if thread is None: + return request.isnt_valid(f'Unknown thread with "threadId":{thread_id}') + target_id = request("targetId", int) + try: + source, line = self._goto_targets_map.obtain_value(target_id) + except KeyError: + return request.isnt_valid("Invalid targetId for goto") + + # Make sure the thread is in the same source file + current_path = inspect.getsourcefile(thread.current_frame.f_code) + current_source = Source(current_path) if current_path is not None else None + if current_source != source: + return request.cant_handle( + f"{source} is not in the same code block as the current frame", + silent=True, + ) + + # Create a callback for when the goto actually finishes. We don't + # want to send our response until then. + def goto_finished(e: Exception | None): + if e is not None: + request.cant_handle( + f"Line {line} is not in the same code block as the current frame", + silent=True, + ) + else: + request.respond({}) + + self._tracer.goto(thread, source, line, goto_finished) + + # Response will happen when the line_change_callback happens + return messaging.NO_RESPONSE + + def exceptionInfo_request(self, request: Request): + thread_id = request("threadId", int) + thread = Thread.get(thread_id) + if thread is None: + raise request.isnt_valid(f'Unknown thread with "threadId":{thread_id}') + exc_info = thread.current_exception + if exc_info is None: + raise request.cant_handle( + f'No current exception on thread with "threadId":{thread_id}' + ) + return exc_info.__getstate__() + + def scopes_request(self, request: Request): + frame_id = request("frameId", int) + frame = StackFrame.get(frame_id) + if frame is None: + # This is fairly common when user quickly resumes after stopping on a breakpoint + # or an exception, such that "scopes" request from the client gets processed at + # the point where the frame is already invalidated. + return request.isnt_valid(f'Invalid "frameId": {frame_id}', silent=True) + return {"scopes": frame.scopes()} + + def _parse_value_format( + self, request: Request, property: str = "format", max_length: int = 1024 + ) -> eval.ValueFormat: + result = eval.ValueFormat( + hex=False, + max_length=max_length, # VSCode limit for tooltips + truncation_suffix="⌇⋯", + circular_ref_marker="↻", + ) + + format = request(property, json.object()) + if format == {}: + return result + + hex = format("hex", bool, optional=True) + if hex != (): + result.hex = hex + + max_length = format("debugpy.maxLength", int, optional=True) + if max_length != (): + result.max_length = max_length + + truncation_suffix = format("debugpy.truncationSuffix", str, optional=True) + if truncation_suffix != (): + result.truncation_suffix = truncation_suffix + + circular_ref_marker = format("debugpy.circularRefMarker", str, optional=True) + if circular_ref_marker != (): + result.circular_ref_marker = circular_ref_marker + + return result + + def _parse_name_format(self, request: Request) -> eval.ValueFormat: + return self._parse_value_format(request, "debugpy.nameFormat", max_length=32) + + def variables_request(self, request: Request): + name_format = self._parse_name_format(request) + value_format = self._parse_value_format(request) + start = request("start", 0) + if start < 0: + return request.isnt_valid(f'Invalid "start": {start}') + + count = request("count", int, optional=True) + if count == (): + count = None + + filter = request("filter", str, optional=True) + match filter: + case (): + filter = {"named", "indexed"} + case "named" | "indexed": + filter = {filter} + case _: + return request.isnt_valid(f'Invalid "filter": {filter!r}') + + container_id = request("variablesReference", int) + container = eval.VariableContainer.get(container_id) + if container is None: + return request.isnt_valid(f'Invalid "variablesReference": {container_id}') + + return { + "variables": list( + container.variables(filter, name_format, value_format, start, count) + ) + } + + def evaluate_request(self, request: Request): + format = self._parse_value_format(request) + expr = request("expression", str) + frame_id = request("frameId", int) + frame = StackFrame.get(frame_id) + if frame is None: + return request.isnt_valid(f'Invalid "frameId": {frame_id}', silent=True) + try: + result = frame.evaluate(expr) + except BaseException as exc: + result = exc + return eval.Result(frame, result, format) + + def setVariable_request(self, request: Request): + format = self._parse_value_format(request) + name = request("name", str) + value = request("value", str) + container_id = request("variablesReference", int) + container = eval.VariableContainer.get(container_id) + if container is None: + raise request.isnt_valid(f'Invalid "variablesReference": {container_id}') + try: + return container.set_variable(name, value, format) + except BaseException as exc: + raise request.cant_handle(str(exc)) + + def setExpression_request(self, request: Request): + expr = request("expression", str) + value = request("value", str) + frame_id = request("frameId", int) + frame = StackFrame.get(frame_id) + if frame is None: + return request.isnt_valid(f'Invalid "frameId": {frame_id}', silent=True) + try: + frame.evaluate(f"{expr} = ({value})", "exec") + result = frame.evaluate(expr) + except BaseException as exc: + raise request.cant_handle(str(exc)) + return eval.Result(frame, result, format) + + def disconnect_request(self, request: Request): + Breakpoint.clear() + self._tracer.abandon_step() + self._tracer.resume() + return {} + + def terminate_request(self, request: Request): + Breakpoint.clear() + self._tracer.abandon_step() + self._tracer.resume() + return {} + + def disconnect(self): + self._tracer.resume() + self.connected_event.clear() + return {} diff --git a/src/debugpy/server/api.py b/src/debugpy/server/api.py index 8fa8767a1..ba50156d3 100644 --- a/src/debugpy/server/api.py +++ b/src/debugpy/server/api.py @@ -4,19 +4,15 @@ import codecs import os -import pydevd import socket import sys import threading -import debugpy from debugpy import adapter from debugpy.common import json, log, sockets -from _pydevd_bundle.pydevd_constants import get_global_debugger -from pydevd_file_utils import absolute_path from debugpy.common.util import hide_debugpy_internals +from debugpy.server.adapters import Adapter -_tls = threading.local() # TODO: "gevent", if possible. _config = { @@ -37,16 +33,17 @@ _adapter_process = None -def _settrace(*args, **kwargs): - log.debug("pydevd.settrace(*{0!r}, **{1!r})", args, kwargs) +def _settrace(host, port, access_token=None, client_access_token=None, **kwargs): + log.debug("pydevd.settrace(*{0!r}, **{1!r})", [host, port], kwargs) + # The stdin in notification is not acted upon in debugpy, so, disable it. kwargs.setdefault("notify_stdin", False) - try: - return pydevd.settrace(*args, **kwargs) - except Exception: - raise - else: - _settrace.called = True + + Adapter.server_access_token = access_token + Adapter.adapter_access_token = client_access_token + + Adapter.connect(host, port) + _settrace.called = True _settrace.called = False @@ -59,8 +56,8 @@ def ensure_logging(): ensure_logging.ensured = True log.to_file(prefix="debugpy.server") log.describe_environment("Initial environment:") - if log.log_dir is not None: - pydevd.log_to(log.log_dir + "/debugpy.pydevd.log") + # if log.log_dir is not None: + # pydevd.log_to(log.log_dir + "/debugpy.pydevd.log") ensure_logging.ensured = False @@ -116,7 +113,7 @@ def debug(address, **kwargs): port.__index__() # ensure it's int-like except Exception: raise ValueError("expected port or (host, port)") - if not (0 <= port < 2 ** 16): + if not (0 <= port < 2**16): raise ValueError("invalid port number") ensure_logging() @@ -125,7 +122,8 @@ def debug(address, **kwargs): qt_mode = _config.get("qt", "none") if qt_mode != "none": - pydevd.enable_qt_support(qt_mode) + # TODO: qt + pass settrace_kwargs = { "suspend": False, @@ -133,9 +131,11 @@ def debug(address, **kwargs): } if hide_debugpy_internals(): - debugpy_path = os.path.dirname(absolute_path(debugpy.__file__)) - settrace_kwargs["dont_trace_start_patterns"] = (debugpy_path,) - settrace_kwargs["dont_trace_end_patterns"] = (str("debugpy_launcher.py"),) + # TODO: hide internals + pass + # debugpy_path = os.path.dirname(absolute_path(debugpy.__file__)) + # settrace_kwargs["dont_trace_start_patterns"] = (debugpy_path,) + # settrace_kwargs["dont_trace_end_patterns"] = (str("debugpy_launcher.py"),) try: return func(address, settrace_kwargs, **kwargs) @@ -153,7 +153,7 @@ def listen(address, settrace_kwargs, in_process_debug_adapter=False): if in_process_debug_adapter: host, port = address log.info("Listening: pydevd without debugpy adapter: {0}:{1}", host, port) - settrace_kwargs['patch_multiprocessing'] = False + settrace_kwargs["patch_multiprocessing"] = False _settrace( host=host, port=port, @@ -218,7 +218,10 @@ def listen(address, settrace_kwargs, in_process_debug_adapter=False): try: global _adapter_process _adapter_process = subprocess.Popen( - adapter_args, close_fds=True, creationflags=creationflags, env=python_env + adapter_args, + close_fds=True, + creationflags=creationflags, + env=python_env, ) if os.name == "posix": # It's going to fork again to daemonize, so we need to wait on it to @@ -228,7 +231,8 @@ def listen(address, settrace_kwargs, in_process_debug_adapter=False): # Suppress misleading warning about child process still being alive when # this process exits (https://bugs.python.org/issue38890). _adapter_process.returncode = 0 - pydevd.add_dont_terminate_child_pid(_adapter_process.pid) + # TODO? + # pydevd.add_dont_terminate_child_pid(_adapter_process.pid) except Exception as exc: log.swallow_exception("Error spawning debug adapter:", level="info") raise RuntimeError("error spawning debug adapter: " + str(exc)) @@ -302,13 +306,10 @@ def __call__(self): ensure_logging() log.debug("wait_for_client()") - pydb = get_global_debugger() - if pydb is None: - raise RuntimeError("listen() or connect() must be called first") - cancel_event = threading.Event() self.cancel = cancel_event.set - pydevd._wait_for_attach(cancel=cancel_event) + # TODO + Adapter.connected_event.wait() @staticmethod def cancel(): @@ -319,7 +320,7 @@ def cancel(): def is_client_connected(): - return pydevd._is_attached() + return Adapter.connected_event.is_set() def breakpoint(): @@ -329,28 +330,13 @@ def breakpoint(): return log.debug("breakpoint()") - # Get the first frame in the stack that's not an internal frame. - pydb = get_global_debugger() - stop_at_frame = sys._getframe().f_back - while ( - stop_at_frame is not None - and pydb.get_file_type(stop_at_frame) == pydb.PYDEV_FILE - ): - stop_at_frame = stop_at_frame.f_back - - _settrace( - suspend=True, - trace_only_current_thread=True, - patch_multiprocessing=False, - stop_at_frame=stop_at_frame, - ) - stop_at_frame = None + # TODO + pass def debug_this_thread(): ensure_logging() log.debug("debug_this_thread()") - _settrace(suspend=False) @@ -358,8 +344,5 @@ def trace_this_thread(should_trace): ensure_logging() log.debug("trace_this_thread({0!r})", should_trace) - pydb = get_global_debugger() - if should_trace: - pydb.enable_tracing() - else: - pydb.disable_tracing() + # TODO + pass diff --git a/src/debugpy/server/cli.py b/src/debugpy/server/cli.py index ba143479a..6aa971994 100644 --- a/src/debugpy/server/cli.py +++ b/src/debugpy/server/cli.py @@ -5,18 +5,10 @@ import json import os import re +import runpy import sys from importlib.util import find_spec -# debugpy.__main__ should have preloaded pydevd properly before importing this module. -# Otherwise, some stdlib modules above might have had imported threading before pydevd -# could perform the necessary detours in it. -assert "pydevd" in sys.modules -import pydevd - -# Note: use the one bundled from pydevd so that it's invisible for the user. -from _pydevd_bundle import pydevd_runpy as runpy - import debugpy from debugpy.common import log from debugpy.server import api @@ -109,7 +101,7 @@ def do(arg, it): port = int(port) except Exception: port = -1 - if not (0 <= port < 2 ** 16): + if not (0 <= port < 2**16): raise ValueError("invalid port number") options.mode = mode diff --git a/src/debugpy/server/eval.py b/src/debugpy/server/eval.py new file mode 100644 index 000000000..522d565ad --- /dev/null +++ b/src/debugpy/server/eval.py @@ -0,0 +1,198 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +""" +DAP entities related to expression evaluation and inspection of variables and scopes. + +Classes here are mostly wrappers around the actual object inspection logic implemented +in debugpy.server.inspect which adapts it to DAP, allowing debugpy.server.inspect to be +unit-tested in isolation. +""" + +import ctypes +import itertools +import debugpy +import threading +from collections.abc import Iterable, Set +from debugpy.common import log +from debugpy.server.inspect import ValueFormat +from debugpy.server.inspect.children import inspect_children +from typing import ClassVar, Literal, Optional, Self + +from debugpy.server.inspect.repr import formatted_repr + +type StackFrame = "debugpy.server.tracing.StackFrame" +type VariableFilter = Set[Literal["named", "indexed"]] + + +_lock = threading.RLock() + + +class VariableContainer: + frame: StackFrame + id: int + + _last_id: ClassVar[int] = 0 + _all: ClassVar[dict[int, "VariableContainer"]] = {} + + def __init__(self, frame: StackFrame): + self.frame = frame + with _lock: + VariableContainer._last_id += 1 + self.id = VariableContainer._last_id + self._all[self.id] = self + + def __getstate__(self) -> dict[str, object]: + return {"variablesReference": self.id} + + def __repr__(self): + return f"{type(self).__name__}({self.id})" + + @classmethod + def get(cls, id: int) -> Self | None: + with _lock: + return cls._all.get(id) + + def variables( + self, + filter: VariableFilter, + name_format: ValueFormat, + value_format: ValueFormat, + start: int = 0, + count: Optional[int] = None, + ) -> Iterable["Variable"]: + raise NotImplementedError + + def set_variable(self, name: str, value: str, format: ValueFormat) -> "Value": + raise NotImplementedError + + @classmethod + def invalidate(self, *frames: Iterable[StackFrame]) -> None: + with _lock: + ids = [ + id + for id, var in self._all.items() + if any(frame is var.frame for frame in frames) + ] + for id in ids: + del self._all[id] + + +class Value(VariableContainer): + value: object + format: ValueFormat + # TODO: memoryReference, presentationHint + + def __init__(self, frame: StackFrame, value: object, format: ValueFormat): + super().__init__(frame) + self.value = value + self.format = format + + def __getstate__(self) -> dict[str, object]: + inspector = inspect_children(self.value) + state = super().__getstate__() + state.update( + { + "type": self.typename, + "value": formatted_repr(self.value, self.format), + "namedVariables": inspector.named_children_count(), + "indexedVariables": inspector.indexed_children_count(), + } + ) + return state + + @property + def typename(self) -> str: + try: + return type(self.value).__name__ + except: + return "" + + def variables( + self, + filter: VariableFilter, + name_format: ValueFormat, + value_format: ValueFormat, + start: int = 0, + count: Optional[int] = None, + ) -> Iterable["Variable"]: + stop = None if count is None else start + count + log.info( + "Computing {0} children of {1!r} in range({2}, {3}).", + filter, + self, + start, + stop, + ) + + inspector = inspect_children(self.value) + children = itertools.chain( + inspector.named_children() if "named" in filter else (), + inspector.indexed_children() if "indexed" in filter else (), + ) + children = itertools.islice(children, start, stop) + for child in children: + yield Variable( + self.frame, child.accessor(name_format), child.value, value_format + ) + + def set_variable(self, name: str, value_expr: str, format: ValueFormat) -> "Value": + value = self.frame.evaluate(value_expr) + if name.startswith("[") and name.endswith("]"): + key_expr = name[1:-1] + key = self.frame.evaluate(key_expr) + self.value[key] = value + result = self.value[key] + else: + setattr(self.value, name, value) + result = getattr(self.value, name) + return Value(self.frame, result, format) + + +class Result(Value): + def __getstate__(self) -> dict[str, object]: + state = super().__getstate__() + state["result"] = state.pop("value") + return state + + +class Variable(Value): + name: str + # TODO: evaluateName + + def __init__( + self, frame: StackFrame, name: str, value: object, format: ValueFormat + ): + super().__init__(frame, value, format) + self.name = name + + def __getstate__(self) -> dict[str, object]: + state = super().__getstate__() + state["name"] = self.name + return state + + +class Scope(Variable): + def __init__(self, frame: StackFrame, name: str, storage: dict[str, object]): + class ScopeObject: + def __dir__(self): + return list(storage.keys()) + + def __getattr__(self, name): + return storage[name] + + def __setattr__(self, name, value): + storage[name] = value + # Until PEP 667 is implemented, this is necessary to propagate the changes + # from the dict to actual locals. + try: + PyFrame_LocalsToFast = ctypes.pythonapi.PyFrame_LocalsToFast + except: + pass + else: + PyFrame_LocalsToFast( + ctypes.py_object(frame.python_frame), ctypes.c_int(0) + ) + + super().__init__(frame, name, ScopeObject(), ValueFormat()) diff --git a/src/debugpy/server/inspect/__init__.py b/src/debugpy/server/inspect/__init__.py new file mode 100644 index 000000000..03da344f7 --- /dev/null +++ b/src/debugpy/server/inspect/__init__.py @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +""" +Object inspection: rendering values, enumerating children etc. + +This module provides a generic non-DAP-aware API with minimal dependencies, so that +it can be unit-tested in isolation without requiring a live debugpy session. + +debugpy.server.eval then wraps it in DAP-specific adapter classes that expose the +same functionality in DAP terms. +""" + +import sys +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class ValueFormat: + hex: bool = False + """Whether integers should be rendered in hexadecimal.""" + + max_length: int = sys.maxsize + """ + Maximum length of the string representation of variable values. + """ + + truncation_suffix: str = "" + """Suffix to append to string representation of value when truncation occurs. + Counts towards max_value_length and max_key_length.""" + + circular_ref_marker: Optional[str] = None + """ + String to use for nested circular references (e.g. list containing itself). If None, + circular references aren't detected and the caller is responsible for avoiding them + in inputs. + """ + \ No newline at end of file diff --git a/src/debugpy/server/inspect/children.py b/src/debugpy/server/inspect/children.py new file mode 100644 index 000000000..09a9f62f7 --- /dev/null +++ b/src/debugpy/server/inspect/children.py @@ -0,0 +1,227 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +import dataclasses +from collections.abc import Iterable, Mapping +from debugpy.common import log +from debugpy.server.inspect import ValueFormat +from debugpy.server.inspect.repr import formatted_repr +from itertools import count + + +class ChildObject: + """ + Represents an object that is a child of another object that is accessible in some way. + """ + + value: object + + def __init__(self, value: object): + self.value = value + + def accessor(self, format: ValueFormat) -> str: + """ + Accessor used to retrieve this object. + + This is a display string and is not intended to be used for eval, but it should + generally correlate to the expression that can be used to retrieve the object in + some clear and obvious way. Some examples of accessors: + + "attr" - value.attr + "[key]" - value[key] + "len()" - len(value) + """ + raise NotImplementedError + + def expr(self, parent_expr: str) -> str: + """ + Returns an expression that can be used to retrieve this object from its parent, + given the expression to compute the parent. + """ + raise NotImplementedError + + +class NamedChildObject(ChildObject): + """ + Child object that has a predefined accessor used to access it. + + This includes not just attributes, but all children that do not require repr() of + index, key etc to compute the accessor. + """ + + def __init__(self, name: str, value: object): + super().__init__(value) + self.name = name + + def accessor(self, format: ValueFormat) -> str: + return self.name + + def expr(self, parent_expr: str) -> str: + accessor = self.accessor(ValueFormat()) + return f"({parent_expr}).{accessor}" + + def __eq__(self, other): + if not isinstance(other, NamedChildObject): + return False + return (self.name, self.value) == (other.name, other.value) + + +class LenChildObject(NamedChildObject): + """ + A synthetic child object that represents the return value of len(). + """ + + def __init__(self, parent: object): + super().__init__("len()", len(parent)) + + def expr(self, parent_expr: str) -> str: + return f"len({parent_expr})" + + +class IndexedChildObject(ChildObject): + """ + Child object that has a computed accessor. + """ + + key: object + + def __init__(self, key: object, value: object): + super().__init__(value) + self.key = key + self.indexer = None + + def accessor(self, format: ValueFormat) -> str: + key_format = dataclasses.replace(format, max_length=format.max_length - 2) + key_repr = formatted_repr(self.key, key_format) + return f"[{key_repr}]" + + def expr(self, parent_expr: str) -> str: + accessor = self.accessor(ValueFormat()) + return f"({parent_expr}){accessor}" + + def __eq__(self, other): + if not isinstance(other, IndexedChildObject): + return False + return (self.key, self.value) == (other.key, other.value) + + +class ObjectInspector: + """ + Inspects a generic object, providing access to its children (attributes, items etc). + """ + + value: object + + def __init__(self, value: object): + self.value = value + + def children(self) -> Iterable[ChildObject]: + yield from self.named_children() + yield from self.indexed_children() + + def indexed_children_count(self) -> int: + try: + return len(self.value) + except: + return 0 + + def indexed_children(self) -> Iterable[IndexedChildObject]: + return () + + def named_children_count(self) -> int: + return len(tuple(self.named_children())) + + def named_children(self) -> Iterable[NamedChildObject]: + def attrs(): + try: + names = dir(self.value) + except: + names = () + + # TODO: group class/instance/function/special + for name in names: + if name.startswith("__"): + continue + try: + value = getattr(self.value, name) + except BaseException as exc: + value = exc + try: + if hasattr(value, "__call__"): + continue + except: + pass + yield NamedChildObject(name, value) + + try: + yield LenChildObject(self.value) + except: + pass + + return sorted(attrs(), key=lambda var: var.name) + + +class IterableInspector(ObjectInspector): + value: Iterable + + def indexed_children(self) -> Iterable[IndexedChildObject]: + yield from super().indexed_children() + try: + it = iter(self.value) + except: + return + for i in count(): + try: + item = next(it) + except StopIteration: + break + except: + log.exception("Error retrieving next item.") + break + yield IndexedChildObject(i, item) + + +class MappingInspector(ObjectInspector): + value: Mapping + + def indexed_children(self) -> Iterable[IndexedChildObject]: + yield from super().indexed_children() + try: + keys = self.value.keys() + except: + return + it = iter(keys) + while True: + try: + key = next(it) + except StopIteration: + break + except: + break + try: + value = self.value[key] + except BaseException as exc: + value = exc + yield IndexedChildObject(key, value) + + +# Indexing str yields str, which is not very useful for debugging. What we want is to +# show the ordinal character values, similar to how it works for bytes & bytearray. +class StrInspector(IterableInspector): + def indexed_children(self) -> Iterable[IndexedChildObject]: + for i, ch in enumerate(self.value): + yield IndexedChildObject(i, ord(ch)) + + +def inspect_children(value: object) -> ObjectInspector: + # TODO: proper extensible registry with public API for debugpy plugins. + match value: + case str(): + return StrInspector(value) + case Mapping(): + return MappingInspector(value) + case Iterable(): + return IterableInspector(value) + case _: + return ObjectInspector(value) diff --git a/src/debugpy/server/inspect/repr.py b/src/debugpy/server/inspect/repr.py new file mode 100644 index 000000000..3d4b9e9f1 --- /dev/null +++ b/src/debugpy/server/inspect/repr.py @@ -0,0 +1,215 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +import functools +import io +from collections.abc import Iterable, Mapping +from typing import Callable +from debugpy.server.inspect import ValueFormat + + +class ReprTooLongError(Exception): + pass + + +class ReprBuilder: + output: io.StringIO + + format: ValueFormat + + path: list[object] + """ + Path to the current object being inspected, starting from the root object on which + repr() was called, with each new element corresponding to a single nest() call. + """ + + chars_remaining: int + """ + How many more characters are allowed in the output. + + Formatters can use this to optimize by appending larger chunks if there is enough + space left for them. However, this is just a hint, and formatters aren't required + to truncate their output - the ReprBuilder will take care of that automatically. + """ + + def __init__(self, format: ValueFormat): + self.output = io.StringIO() + self.format = format + self.path = [] + self.chars_remaining = self.format.max_length + + def __str__(self) -> str: + return self.output.getvalue() + + def append_text(self, text: str): + self.output.write(text) + self.chars_remaining -= len(text) + if self.chars_remaining < 0: + self.output.seek( + self.format.max_length - len(self.format.truncation_suffix) + ) + self.output.truncate() + self.output.write(self.format.truncation_suffix) + raise ReprTooLongError + + def append_object(self, value: object): + circular_ref_marker = self.format.circular_ref_marker + if circular_ref_marker is not None and any(x is value for x in self.path): + self.append_text(circular_ref_marker) + return + + formatter = get_formatter(value) + self.path.append(value) + try: + formatter(value, self) + finally: + self.path.pop() + + +def format_object(value: object, builder: ReprBuilder): + try: + result = repr(value) + except BaseException as exc: + try: + result = f"" + except: + result = "" + builder.append_text(result) + + +def format_int(value: int, builder: ReprBuilder): + fs = "{:#x}" if builder.format.hex else "{}" + text = fs.format(value) + builder.append_text(text) + + +def format_iterable( + value: Iterable, builder: ReprBuilder, *, prefix: str = None, suffix: str = None +): + if prefix is None: + prefix = type(value).__name__ + "((" + builder.append_text(prefix) + + for i, item in enumerate(value): + if i > 0: + builder.append_text(", ") + builder.append_object(item) + + if suffix is None: + suffix = "))" + builder.append_text(suffix) + + +def format_mapping( + value: Mapping, builder: ReprBuilder, *, prefix: str = None, suffix: str = None +): + if prefix is None: + prefix = type(value).__name__ + "((" + builder.append_text(prefix) + + for i, (key, value) in enumerate(value.items()): + if i > 0: + builder.append_text(", ") + builder.append_object(key) + builder.append_text(": ") + builder.append_object(value) + + if suffix is None: + suffix = "))" + builder.append_text(suffix) + + +def format_strlike( + value: str | bytes | bytearray, builder: ReprBuilder, *, prefix: str, suffix: str +): + builder.append_text(prefix) + + i = 0 + while i < len(value): + # Optimistically assume that no escaping will be needed. + chunk_size = max(1, builder.chars_remaining) + chunk = repr(value[i : i + chunk_size]) + chunk = chunk[len(prefix) : -len(suffix)] + builder.append_text(chunk) + i += chunk_size + + builder.append_text(suffix) + + +def format_tuple(value: tuple, builder: ReprBuilder): + suffix = ",)" if len(value) == 1 else ")" + format_iterable(value, builder, prefix="(", suffix=suffix) + + +format_str = functools.partial(format_strlike, prefix="'", suffix="'") + +format_bytes = functools.partial(format_strlike, prefix="b'", suffix="'") + +format_bytearray = functools.partial(format_strlike, prefix="bytearray(b'", suffix="')") + +format_list = functools.partial(format_iterable, prefix="[", suffix="]") + +format_set = functools.partial(format_iterable, prefix="{", suffix="}") + +format_frozenset = functools.partial(format_iterable, prefix="frozenset({", suffix="})") + +format_dict = functools.partial(format_mapping, prefix="{", suffix="}") + + +type Formatter = Callable[[object, ReprBuilder]] + +formatters: Mapping[type, Formatter] = { + int: format_int, + str: format_str, + bytes: format_bytes, + bytearray: format_bytearray, + tuple: format_tuple, + list: format_list, + set: format_set, + frozenset: format_frozenset, + dict: format_dict, +} + + +def get_formatter(value: object) -> Formatter: + # TODO: proper extensible registry with public API for debugpy plugins. + + # First let's see if we have a formatter for this specific type. Matching on type + # here must be exact, i.e. no subtypes. The reason for this is that repr must, + # insofar as possible, reconstitute the original object if eval'd; but if we use + # a base class repr for a subclass, evaling it will produce instance of that base + # class instead. This matters when editing variable values, since repr of the value + # is the text that user will be editing. So if we show a dict repr for a subclass + # of dict, and user edits it and saves, the value will be silently overwritten with + # a plain dict. To avoid data loss, we must always use generic repr in cases where + # we don't know the type exactly. + formatter = formatters.get(type(value), None) + if formatter is not None: + return formatter + + # If there's no specific formatter for this type, pick a generic formatter instead. + # For this, we do want subtype matching, because those generic formatters produce + # repr that includes the type name following the standard pattern for those types - + # so e.g. a Sequence of type T will be formatted as "T((items))". + match value: + case Mapping(): + return format_mapping + case Iterable(): + return format_iterable + case _: + return format_object + + +def formatted_repr(value: object, format: ValueFormat) -> str: + builder = ReprBuilder(format) + try: + builder.append_object(value) + except ReprTooLongError: + pass + except BaseException as exc: + try: + builder.append_text(f"") + except: + builder.append_text("") + return str(builder) diff --git a/src/debugpy/server/safe_repr.py b/src/debugpy/server/safe_repr.py new file mode 100644 index 000000000..fb0a0ea0c --- /dev/null +++ b/src/debugpy/server/safe_repr.py @@ -0,0 +1,412 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +import sys +import locale + +from debugpy.common import log + + +class SafeRepr(object): + # Can be used to override the encoding from locale.getpreferredencoding() + locale_preferred_encoding = None + + # Can be used to override the encoding used for sys.stdout.encoding + sys_stdout_encoding = None + + # String types are truncated to maxstring_outer when at the outer- + # most level, and truncated to maxstring_inner characters inside + # collections. + maxstring_outer = 2**16 + maxstring_inner = 30 + string_types = (str, bytes) + bytes = bytes + set_info = (set, "{", "}", False) + frozenset_info = (frozenset, "frozenset({", "})", False) + int_types = (int,) + long_iter_types = (list, tuple, bytearray, range, dict, set, frozenset) + + # Collection types are recursively iterated for each limit in + # maxcollection. + maxcollection = (15, 10) + + # Specifies type, prefix string, suffix string, and whether to include a + # comma if there is only one element. (Using a sequence rather than a + # mapping because we use isinstance() to determine the matching type.) + collection_types = [ + (tuple, "(", ")", True), + (list, "[", "]", False), + frozenset_info, + set_info, + ] + try: + from collections import deque + + collection_types.append((deque, "deque([", "])", False)) + except Exception: + pass + + # type, prefix string, suffix string, item prefix string, + # item key/value separator, item suffix string + dict_types = [(dict, "{", "}", "", ": ", "")] + try: + from collections import OrderedDict + + dict_types.append((OrderedDict, "OrderedDict([", "])", "(", ", ", ")")) + except Exception: + pass + + # All other types are treated identically to strings, but using + # different limits. + maxother_outer = 2**16 + maxother_inner = 30 + + convert_to_hex = False + raw_value = False + + def __call__(self, obj): + """ + :param object obj: + The object for which we want a representation. + + :return str: + Returns bytes encoded as utf-8 on py2 and str on py3. + """ + try: + return "".join(self._repr(obj, 0)) + except Exception: + try: + return "An exception was raised: %r" % sys.exc_info()[1] + except Exception: + return "An exception was raised" + + def _repr(self, obj, level): + """Returns an iterable of the parts in the final repr string.""" + + try: + obj_repr = type(obj).__repr__ + except Exception: + obj_repr = None + + def has_obj_repr(t): + r = t.__repr__ + try: + return obj_repr == r + except Exception: + return obj_repr is r + + for t, prefix, suffix, comma in self.collection_types: + if isinstance(obj, t) and has_obj_repr(t): + return self._repr_iter(obj, level, prefix, suffix, comma) + + for ( + t, + prefix, + suffix, + item_prefix, + item_sep, + item_suffix, + ) in self.dict_types: # noqa + if isinstance(obj, t) and has_obj_repr(t): + return self._repr_dict( + obj, level, prefix, suffix, item_prefix, item_sep, item_suffix + ) + + for t in self.string_types: + if isinstance(obj, t) and has_obj_repr(t): + return self._repr_str(obj, level) + + if self._is_long_iter(obj): + return self._repr_long_iter(obj) + + return self._repr_other(obj, level) + + # Determines whether an iterable exceeds the limits set in + # maxlimits, and is therefore unsafe to repr(). + def _is_long_iter(self, obj, level=0): + try: + # Strings have their own limits (and do not nest). Because + # they don't have __iter__ in 2.x, this check goes before + # the next one. + if isinstance(obj, self.string_types): + return len(obj) > self.maxstring_inner + + # If it's not an iterable (and not a string), it's fine. + if not hasattr(obj, "__iter__"): + return False + + # If it's not an instance of these collection types then it + # is fine. Note: this is a fix for + # https://github.com/Microsoft/ptvsd/issues/406 + if not isinstance(obj, self.long_iter_types): + return False + + # Iterable is its own iterator - this is a one-off iterable + # like generator or enumerate(). We can't really count that, + # but repr() for these should not include any elements anyway, + # so we can treat it the same as non-iterables. + if obj is iter(obj): + return False + + # range reprs fine regardless of length. + if isinstance(obj, range): + return False + + # numpy and scipy collections (ndarray etc) have + # self-truncating repr, so they're always safe. + try: + module = type(obj).__module__.partition(".")[0] + if module in ("numpy", "scipy"): + return False + except Exception: + pass + + # Iterables that nest too deep are considered long. + if level >= len(self.maxcollection): + return True + + # It is too long if the length exceeds the limit, or any + # of its elements are long iterables. + if hasattr(obj, "__len__"): + try: + size = len(obj) + except Exception: + size = None + if size is not None and size > self.maxcollection[level]: + return True + return any( + (self._is_long_iter(item, level + 1) for item in obj) + ) # noqa + return any( + i > self.maxcollection[level] or self._is_long_iter(item, level + 1) + for i, item in enumerate(obj) + ) # noqa + + except Exception: + # If anything breaks, assume the worst case. + return True + + def _repr_iter(self, obj, level, prefix, suffix, comma_after_single_element=False): + yield prefix + + if level >= len(self.maxcollection): + yield "..." + else: + count = self.maxcollection[level] + yield_comma = False + for item in obj: + if yield_comma: + yield ", " + yield_comma = True + + count -= 1 + if count <= 0: + yield "..." + break + + for p in self._repr(item, 100 if item is obj else level + 1): + yield p + else: + if comma_after_single_element: + if count == self.maxcollection[level] - 1: + yield "," + yield suffix + + def _repr_long_iter(self, obj): + try: + length = hex(len(obj)) if self.convert_to_hex else len(obj) + obj_repr = "<%s, len() = %s>" % (type(obj).__name__, length) + except Exception: + try: + obj_repr = "<" + type(obj).__name__ + ">" + except Exception: + obj_repr = "" + yield obj_repr + + def _repr_dict( + self, obj, level, prefix, suffix, item_prefix, item_sep, item_suffix + ): + if not obj: + yield prefix + suffix + return + if level >= len(self.maxcollection): + yield prefix + "..." + suffix + return + + yield prefix + + count = self.maxcollection[level] + yield_comma = False + + for key in list(obj): + if yield_comma: + yield ", " + yield_comma = True + + count -= 1 + if count <= 0: + yield "..." + break + + yield item_prefix + for p in self._repr(key, level + 1): + yield p + + yield item_sep + + try: + item = obj[key] + except Exception: + yield "" + else: + for p in self._repr(item, 100 if item is obj else level + 1): + yield p + yield item_suffix + + yield suffix + + def _repr_str(self, obj, level): + try: + if self.raw_value: + # For raw value retrieval, ignore all limits. + if isinstance(obj, bytes): + yield obj.decode("latin-1") + else: + yield obj + return + + limit_inner = self.maxother_inner + limit_outer = self.maxother_outer + limit = limit_inner if level > 0 else limit_outer + if len(obj) <= limit: + # Note that we check the limit before doing the repr (so, the final string + # may actually be considerably bigger on some cases, as besides + # the additional u, b, ' chars, some chars may be escaped in repr, so + # even a single char such as \U0010ffff may end up adding more + # chars than expected). + yield self._convert_to_unicode_or_bytes_repr(repr(obj)) + return + + # Slightly imprecise calculations - we may end up with a string that is + # up to 6 characters longer than limit. If you need precise formatting, + # you are using the wrong class. + left_count, right_count = max(1, int(2 * limit / 3)), max( + 1, int(limit / 3) + ) # noqa + + # Important: only do repr after slicing to avoid duplicating a byte array that could be + # huge. + + # Note: we don't deal with high surrogates here because we're not dealing with the + # repr() of a random object. + # i.e.: A high surrogate unicode char may be splitted on Py2, but as we do a `repr` + # afterwards, that's ok. + + # Also, we just show the unicode/string/bytes repr() directly to make clear what the + # input type was (so, on py2 a unicode would start with u' and on py3 a bytes would + # start with b'). + + part1 = obj[:left_count] + part1 = repr(part1) + part1 = part1[: part1.rindex("'")] # Remove the last ' + + part2 = obj[-right_count:] + part2 = repr(part2) + part2 = part2[ + part2.index("'") + 1 : + ] # Remove the first ' (and possibly u or b). + + yield part1 + yield "..." + yield part2 + except: + # This shouldn't really happen, but let's play it safe. + log.exception("Error getting string representation to show.") + for part in self._repr_obj( + obj, level, self.maxother_inner, self.maxother_outer + ): + yield part + + def _repr_other(self, obj, level): + return self._repr_obj(obj, level, self.maxother_inner, self.maxother_outer) + + def _repr_obj(self, obj, level, limit_inner, limit_outer): + try: + if self.raw_value: + # For raw value retrieval, ignore all limits. + if isinstance(obj, bytes): + yield obj.decode("latin-1") + return + + try: + mv = memoryview(obj) + except Exception: + yield self._convert_to_unicode_or_bytes_repr(repr(obj)) + return + else: + # Map bytes to Unicode codepoints with same values. + yield mv.tobytes().decode("latin-1") + return + elif self.convert_to_hex and isinstance(obj, self.int_types): + obj_repr = hex(obj) + else: + obj_repr = repr(obj) + except Exception: + try: + obj_repr = object.__repr__(obj) + except Exception: + try: + obj_repr = ( + "" + ) # noqa + except Exception: + obj_repr = "" + + limit = limit_inner if level > 0 else limit_outer + + if limit >= len(obj_repr): + yield self._convert_to_unicode_or_bytes_repr(obj_repr) + return + + # Slightly imprecise calculations - we may end up with a string that is + # up to 3 characters longer than limit. If you need precise formatting, + # you are using the wrong class. + left_count, right_count = max(1, int(2 * limit / 3)), max( + 1, int(limit / 3) + ) # noqa + + yield obj_repr[:left_count] + yield "..." + yield obj_repr[-right_count:] + + def _convert_to_unicode_or_bytes_repr(self, obj_repr): + return obj_repr + + def _bytes_as_unicode_if_possible(self, obj_repr): + # We try to decode with 3 possible encoding (sys.stdout.encoding, + # locale.getpreferredencoding() and 'utf-8). If no encoding can decode + # the input, we return the original bytes. + try_encodings = [] + encoding = self.sys_stdout_encoding or getattr(sys.stdout, "encoding", "") + if encoding: + try_encodings.append(encoding.lower()) + + preferred_encoding = ( + self.locale_preferred_encoding or locale.getpreferredencoding() + ) + if preferred_encoding: + preferred_encoding = preferred_encoding.lower() + if preferred_encoding not in try_encodings: + try_encodings.append(preferred_encoding) + + if "utf-8" not in try_encodings: + try_encodings.append("utf-8") + + for encoding in try_encodings: + try: + return obj_repr.decode(encoding) + except UnicodeDecodeError: + pass + + return obj_repr # Return the original version (in bytes) diff --git a/src/debugpy/server/tracing/__init__.py b/src/debugpy/server/tracing/__init__.py new file mode 100644 index 000000000..8814f67ab --- /dev/null +++ b/src/debugpy/server/tracing/__init__.py @@ -0,0 +1,700 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +import re +import threading +import traceback +from collections import defaultdict +from collections.abc import Callable, Iterable +from debugpy import server +from debugpy.common import log +from debugpy.server import new_dap_id +from debugpy.server.eval import Scope, VariableContainer +from enum import Enum +from pathlib import Path +from sys import monitoring +from types import CodeType, FrameType, FunctionType +from typing import Any, ClassVar, Generator, Literal, Union, override + +# Shared for all global state pertaining to breakpoints and stepping. +_cvar = threading.Condition() + + +def is_internal_python_frame(frame: FrameType) -> bool: + # TODO: filter internal frames properly + parts = Path(frame.f_code.co_filename).parts + internals = ["debugpy", "threading"] + return any(part.startswith(s) for s in internals for part in parts) + + +class Source: + """ + Represents a DAP Source object. + """ + + path: str + """ + Path to the source file; immutable. Note that this needs not be an actual valid + path on the filesystem; values such as or are also allowed. + """ + + # TODO: support "sourceReference" for cases where path isn't available (e.g. decompiled code) + + def __init__(self, path: str): + # If it is a valid file path, we want to resolve and normalize it, so that it + # can be unambiguously compared to code object paths later. + try: + path = str(Path(path).resolve()) + except (OSError, RuntimeError): + # Something like or + pass + self.path = path + + def __getstate__(self) -> dict: + return {"path": self.path} + + def __repr__(self) -> str: + return f"Source({self.path!r})" + + def __str__(self) -> str: + return self.path + + def __eq__(self, other) -> bool: + if not isinstance(other, Source): + return False + return self.path == other.path + + def __hash__(self) -> int: + return hash(self.path) + + +class ExceptionBreakMode(Enum): + NEVER = "never" + ALWAYS = "always" + UNHANDLED = "unhandled" + USER_UNHANDLED = "userUnhandled" + + +class ExceptionInfo: + """ + Information about exception that is reported to the DAP client. + """ + + exception: BaseException + """The exception that is being reported.""" + + break_mode: ExceptionBreakMode + """The break mode that triggered the reporting.""" + + def __init__(self, exception: BaseException, break_mode: ExceptionBreakMode): + self.exception = exception + self.break_mode = break_mode + + def __getstate__(self) -> dict: + exc_type = type(self.exception) + desc = "" + if self.break_mode == ExceptionBreakMode.UNHANDLED: + desc += "(unhandled) " + try: + desc += str(self.exception) + except: + try: + desc += repr(self.exception) + except: + pass + result = { + "exceptionId": exc_type.__qualname__, + "description": desc, + } + return result + + +class Thread: + """ + Represents a DAP Thread object. Instances must never be created directly; + use Thread.from_python_thread() instead. + """ + + id: int + """DAP ID of this thread. Distinct from thread.ident.""" + + python_thread: threading.Thread + """The Python thread object this DAP Thread represents.""" + + current_exception: ExceptionInfo | None + """ + The exception currently being propagated on this thread, if any. + """ + + is_known_to_adapter: bool + """ + Whether this thread has been reported to the adapter via the + DAP "thread" event with "reason":"started". + """ + + is_traced: bool + """ + Whether this thread is traced. Threads are normally traced, but API clients + can exclude a specific thread from tracing. + """ + + pending_callback: FunctionType | None + """ + As a result of a https://microsoft.github.io/debug-adapter-protocol/specification#Requests_Goto + this is a callback to run when the thread is notified after a goto + """ + + _all: ClassVar[dict[int, "Thread"]] = {} + _current_frame: FrameType | None + _cached_stack: list["StackFrame"] | None + + def __init__(self, python_thread: threading.Thread): + """ + Create a new Thread object for the given thread. Do not invoke directly; + use Thread.get() instead. + """ + + self.python_thread = python_thread + self.current_frame = None + self.is_known_to_adapter = False + self.is_traced = True + self.pending_callback = None + + # Thread IDs are serialized as JSON numbers in DAP, which are handled as 64-bit + # floats by most DAP clients. However, OS thread IDs can be large 64-bit integers + # on some platforms. To avoid loss of precision, we map all thread IDs to 32-bit + # signed integers; if the original ID fits, we use it as is, otherwise we use a + # generated negative ID that is guaranteed to fit. + self.id = self.python_thread.ident + assert self.id is not None + + if self.id < 0 or self.id != float(self.id): + self.id = new_dap_id() + self._all[self.id] = self + + log.info( + f"DAP {self} created for Python Thread(ident={self.python_thread.ident})" + ) + + def __repr__(self) -> str: + return f"Thread({self.id})" + + def __getstate__(self) -> dict: + return { + "id": self.id, + "name": self.name, + } + + @property + def name(self) -> str: + return self.python_thread.name + + @property + def current_frame(self) -> FrameType | None: + """ + The Python frame object corresponding to the topmost stack frame on this thread + if it is suspended, or None if it is running. + """ + return self._current_frame + + @current_frame.setter + def current_frame(self, val: FrameType | None): + # Clear our stack frame list whenever the current frame changes + self._cached_stack = None + self._current_frame = val + + @classmethod + def from_python_thread(self, python_thread: threading.Thread) -> "Thread": + """ + Returns the DAP Thread object corresponding to the given Python thread, or for + the current Python thread if None, creating it and reporting it to adapter if + necessary. If the current thread is internal debugpy thread, returns None. + """ + if python_thread is None: + python_thread = threading.current_thread() + if python_thread.ident is None: + return None + if getattr(python_thread, "is_debugpy_thread", False): + return None + with _cvar: + for thread in self._all.values(): + if thread.python_thread.ident == python_thread.ident: + break + else: + thread = Thread(python_thread) + thread.make_known_to_adapter() + return thread + + @classmethod + def get(self, id: int) -> Union["Thread", None]: + """ + Finds a thread by its DAP ID. Returns None if ID is unknown. + """ + with _cvar: + return self._all.get(id, None) + + @classmethod + def enumerate(self) -> list["Thread"]: + """ + Returns all running threads in this process. + """ + return [thread for thread in self._all.values() if thread.is_traced] + + def make_known_to_adapter(self): + """ + If adapter is connected to this process, reports this thread to it via DAP + "thread" event with "reason":"started" if it hasn't been reported already. + Returns True if thread is now known to the adapter, and False if there was + no adapter to report it to. + """ + with _cvar: + if not self.is_traced: + return False + if self.is_known_to_adapter: + return True + adapter = server.adapter() + if adapter is None: + return False + adapter.channel.send_event( + "thread", + { + "reason": "started", + "threadId": self.id, + "name": self.name, + }, + ) + self.is_known_to_adapter = True + return True + + + def get_stack_trace(self) -> list["StackFrame"]: + """ + Returns a list of StackFrame objects for the current stack of this thread, + starting with the topmost frame. + """ + # If our current frame is none, this is invalid. Throw an error. + if self._current_frame is None: + raise ValueError(reason="Thread is not suspended") + if self._cached_stack is None: + self._cached_stack = list(self._generate_stack_trace()) + return self._cached_stack + + def _generate_stack_trace(self) -> Generator["StackFrame", Any, None]: + try: + with _cvar: + python_frame = self.current_frame + except ValueError: + raise ValueError(f"Can't get frames for inactive Thread({self.id})") + for python_frame, _ in traceback.walk_stack(python_frame): + frame = StackFrame.from_python_frame(self, python_frame) + log.info("{0}", f"{self}: {frame}") + if not is_internal_python_frame(python_frame): + yield frame + log.info("{0}", f"{self}: End stack trace.") + + +class StackFrame: + """ + Represents a DAP StackFrame object. Instances must never be created directly; + use StackFrame.from_python_frame() instead. + """ + + id: int + + thread: Thread + python_frame: FrameType + + _source: Source | None + _scopes: list[Scope] + + _all: ClassVar[dict[int, "StackFrame"]] = {} + + def __init__(self, thread: Thread, python_frame: FrameType): + """ + Create a new StackFrame object for the given thread and frame object. Do not + invoke directly; use StackFrame.from_python_frame() instead. + """ + self.id = new_dap_id() + self.thread = thread + self.python_frame = python_frame + self._source = None + self._scopes = None + self._all[self.id] = self + + def __getstate__(self) -> dict: + return { + "id": self.id, + "name": self.python_frame.f_code.co_name, + "source": self.source(), + "line": self.python_frame.f_lineno, + "column": 1, # TODO + # TODO: "endLine", "endColumn", "moduleId", "instructionPointerReference" + } + + def __repr__(self) -> str: + result = f"StackFrame({self.id}, {self.python_frame}" + if is_internal_python_frame(self.python_frame): + result += ", internal=True" + result += ")" + return result + + @property + def line(self) -> int: + return self.python_frame.f_lineno + + def source(self) -> Source: + if self._source is None: + # No need to sync this since all instances created from the same path + # are equivalent for all purposes. + self._source = Source(self.python_frame.f_code.co_filename) + return self._source + + @classmethod + def from_python_frame( + self, thread: Thread, python_frame: FrameType + ) -> "StackFrame": + for frame in self._all.values(): + if frame.thread is thread and frame.python_frame is python_frame: + return frame + return StackFrame(thread, python_frame) + + @classmethod + def get(self, id: int) -> "StackFrame": + return self._all.get(id, None) + + def scopes(self) -> list[Scope]: + if self._scopes is None: + self._scopes = [ + Scope(self, "local", self.python_frame.f_locals), + Scope(self, "global", self.python_frame.f_globals), + ] + return self._scopes + + def evaluate( + self, source: str, mode: Literal["eval", "exec", "single"] = "eval" + ) -> object: + code = compile(source, "", mode) + return eval(code, self.python_frame.f_globals, self.python_frame.f_locals) + + @classmethod + def invalidate(self, thread: Thread): + frames = [frame for frame in self._all.values() if frame.thread is thread] + VariableContainer.invalidate(*frames) + for frame in frames: + del self._all[frame.id] + frame.python_frame = None + + +class Step: + step: Literal["in", "out", "over"] + + def __init__( + self, + origin: FrameType = None, + origin_line: int = None, + ): + self.origin = origin + self.origin_line = origin_line + + def __repr__(self): + return f"Step({self.step})" + + def is_complete(self, python_frame: FrameType) -> bool: + raise ValueError(f"Unknown step type: {self.step}") + + +class StepIn(Step): + step = "in" + + @override + def is_complete(self, python_frame: FrameType) -> bool: + return ( + python_frame is not self.origin or python_frame.f_lineno != self.origin_line + ) + + +class StepOver(Step): + step = "over" + + @override + def is_complete(self, python_frame: FrameType) -> bool: + is_complete = True + for python_frame, _ in traceback.walk_stack(python_frame): + if ( + python_frame is self.origin + and python_frame.f_lineno == self.origin_line + ): + is_complete = False + break + return is_complete + + +class StepOut(Step): + step = "out" + + @override + def is_complete(self, python_frame: FrameType) -> bool: + is_complete = True + for python_frame, _ in traceback.walk_stack(python_frame): + if python_frame is self.origin: + is_complete = False + break + return is_complete + + +class Condition: + """ + Expression that must be true for the breakpoint to be triggered. + """ + + id: int + """Used to identify the condition in stack traces. Should match breakpoint ID.""" + + expression: str + """Python expression that must evaluate to True for the breakpoint to be triggered.""" + + _code: CodeType + + def __init__(self, id: int, expression: str): + self.id = id + self.expression = expression + self._code = compile(expression, f"breakpoint-{id}-condition", "eval") + + def test(self, frame: StackFrame) -> bool: + """ + Returns True if the breakpoint should be triggered in the specified frame. + """ + try: + result = eval( + self._code, + frame.python_frame.f_globals, + frame.python_frame.f_locals, + ) + return bool(result) + except BaseException as exc: + log.error( + f"Exception while evaluating breakpoint condition ({self.expression}): {exc}" + ) + return False + + +class HitCondition: + """ + Hit count expression that must be True for the breakpoint to be triggered. + + Must have the format `[]`, where is a positive integer literal, + and is one of `==` `>` `>=` `<` `<=` `%`, defaulting to `==` if unspecified. + + Examples: + 5: break on the 5th hit + ==5: ditto + >5: break on every hit after the 5th + >=5: break on the 5th hit and thereafter + %5: break on every 5th hit + """ + + _OPERATORS = { + "==": lambda expected_count, count: count == expected_count, + ">": lambda expected_count, count: count > expected_count, + ">=": lambda expected_count, count: count >= expected_count, + "<": lambda expected_count, count: count < expected_count, + "<=": lambda expected_count, count: count <= expected_count, + "%": lambda expected_count, count: count % expected_count == 0, + } + + id: int + """Used to identify the condition in stack traces. Should match breakpoint ID.""" + + hit_condition: str + """Hit count expression.""" + + _count: int + _operator: Callable[[int, int], bool] + + def __init__(self, id: int, hit_condition: str): + self.id = id + self.hit_condition = hit_condition + m = re.match(r"^\D*(\d+)$", hit_condition) + if not m: + raise SyntaxError(f"Invalid hit condition: {hit_condition}") + self._count = int(m.group(2)) + try: + op = self._OPERATORS[m.group(1) or "=="] + except KeyError: + raise SyntaxError(f"Invalid hit condition operator: {op}") + self.test = lambda count: op(self._count, count) + + def test(self, count: int) -> bool: + """ + Returns True if the breakpoint should be triggered on the given hit count. + """ + # __init__ replaces this method with an actual implementation from _OPERATORS + # when it parses the condition. + raise NotImplementedError + + +class LogMessage: + """ + A message with spliced expressions, to be logged when a breakpoint is triggered. + """ + + id: int + """Used to identify the condition in stack traces. Should match breakpoint ID.""" + + message: str + """The message to be logged. May contain expressions in curly braces.""" + + _code: CodeType + """Compiled code object for the f-string corresponding to the message.""" + + def __init__(self, id: int, message: str): + self.id = id + self.message = message + f_string = "f" + repr(message) + self._code = compile(f_string, f"breakpoint-{id}-logMessage", "eval") + + def format(self, frame: StackFrame) -> str: + """ + Formats the message using the specified frame's locals and globals. + """ + try: + return eval( + self._code, frame.python_frame.f_globals, frame.python_frame.f_locals + ) + except BaseException as exc: + log.exception( + f"Exception while formatting breakpoint log message f{self.message!r}: {exc}" + ) + return self.message + + +class Breakpoint: + """ + Represents a DAP Breakpoint. + """ + + id: int + source: Source + line: int + is_enabled: bool + + condition: Condition | None + + hit_condition: HitCondition | None + + log_message: LogMessage | None + + hit_count: int + """Number of times this breakpoint has been hit.""" + + _all: ClassVar[dict[int, "Breakpoint"]] = {} + + _at: ClassVar[dict[Source, dict[int, list["Breakpoint"]]]] = defaultdict( + lambda: defaultdict(lambda: []) + ) + + # ID must be explicitly specified so that conditions and log message can + # use the same ID - this makes for better call stacks and error messages. + def __init__( + self, + id: int, + source: Source, + line: int, + *, + condition: Condition | None = None, + hit_condition: HitCondition | None = None, + log_message: LogMessage | None = None, + ): + self.id = id + self.source = source + self.line = line + self.is_enabled = True + self.condition = condition + self.hit_condition = hit_condition + self.log_message = log_message + self.hit_count = 0 + + with _cvar: + self._all[self.id] = self + self._at[self.source][self.line].append(self) + _cvar.notify_all() + monitoring.restart_events() + + def __getstate__(self) -> dict: + return { + "line": self.line, + "verified": True, # TODO + } + + @classmethod + def at(self, source: Source, line: int) -> list["Breakpoint"]: + """ + Returns a list of all breakpoints at the specified location. + """ + with _cvar: + return self._at[source][line] + + @classmethod + def clear(self, sources: Iterable[Source] = None): + """ + Removes all breakpoints in the specified files, or all files if None. + """ + with _cvar: + if sources is None: + sources = list(self._at.keys()) + for source in sources: + bps_in = self._at.pop(source, {}).values() + for bps_at in bps_in: + for bp in bps_at: + del self._all[bp.id] + _cvar.notify_all() + monitoring.restart_events() + + def enable(self, is_enabled: bool): + """ + Enables or disables this breakpoint. + """ + with _cvar: + self.is_enabled = is_enabled + _cvar.notify_all() + + def is_triggered(self, frame: StackFrame) -> bool | str: + """ + Determines whether this breakpoint is triggered by the current line in the + specified stack frame, and updates its hit count. + + If the breakpoint is triggered, returns a truthy value; if the breakpoint has + a log message, it is formatted and returned, otherwise True is returned. + """ + with _cvar: + # Check source last since path resolution is potentially expensive. + if ( + not self.is_enabled + or frame.line != self.line + or frame.source() != self.source + ): + return False + + # Hit count must be updated even if conditions are false and execution + # isn't stopped. + self.hit_count += 1 + + # Check hit_condition first since it is faster than checking condition. + if self.hit_condition is not None and not self.hit_condition.test( + self.hit_count + ): + return False + if self.condition is not None and not self.condition.test(frame): + return False + + # If this is a logpoint, return the formatted message instead of True. + if self.log_message is not None: + return self.log_message.format(frame) + + return True + + +# sys.monitoring callbacks are defined in a separate submodule to enable tighter +# control over their use of global state; see comment there for details. +from .tracer import tracer # noqa diff --git a/src/debugpy/server/tracing/tracer.py b/src/debugpy/server/tracing/tracer.py new file mode 100644 index 000000000..6c0896ca6 --- /dev/null +++ b/src/debugpy/server/tracing/tracer.py @@ -0,0 +1,567 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +import gc +import inspect +import sys +import threading +import traceback +from collections.abc import Iterable +import warnings +from debugpy import server +from debugpy.server.tracing import ( + Breakpoint, + ExceptionBreakMode, + ExceptionInfo, + Source, + StackFrame, + Step, + StepIn, + StepOut, + StepOver, + Thread, + _cvar, + is_internal_python_frame, +) +from sys import monitoring +from types import CodeType, FrameType, FunctionType, TracebackType +from typing import Literal + + +class Log: + """ + Safe logging for Tracer. Delegates to debugpy.common.log, but only when it is + safe to do so (i.e. not during finalization). + """ + + def __init__(self): + import atexit + from debugpy.common import log + + def nop(*args, **kwargs): + pass + + @atexit.register + def disable(): + self.debug = self.info = self.warning = self.error = self.exception = nop + + self.debug = lambda *args, **kwargs: log.debug("{0}", *args, **kwargs) + self.info = lambda *args, **kwargs: log.info("{0}", *args, **kwargs) + self.warning = lambda *args, **kwargs: log.warning("{0}", *args, **kwargs) + self.error = lambda *args, **kwargs: log.error("{0}", *args, **kwargs) + self.exception = lambda *args, **kwargs: log.exception("{0}", *args, **kwargs) + + # self.debug = nop # TODO: improve logging performance enough to enable this. + + +log = Log() +del Log + + +# Unhandled exceptions are reported via sys.excepthook & threading.excepthook, which +# aren't sys.monitoring callbacks; thus, they are traced by sys.monitoring like any +# other code. To avoid issues stemming from that, our excepthook wraps the original +# unhandled exception into an instance of this class and re-raises it, which can then +# be processed by _trace_raise like all other exceptions. +class UnhandledException(Exception): + """ + Raised when an exception is not handled by any of the registered handlers. + """ + + exception: BaseException + + def __init__(self, exc: BaseException): + self.exception = exc + + +class Tracer: + """ + Singleton that manages sys.monitoring callbacks for this process. + """ + + CONTROL_FLOW_EXCEPTIONS = (StopIteration, StopAsyncIteration, GeneratorExit) + """ + Exception types that are used by Python itself to implement control flow in loops + and generators. Reporting these exceptions would be extremely chatty and serve no + useful purpose, so they are always ignored unless unhandled. + """ + + exception_break_mode: ExceptionBreakMode + + _stopped_by: Thread | None + """ + If not None, indicates the thread on which the event that caused the debuggee + to enter suspended state has occurred. When any other thread observes a non-None + value of this attribute, it must immediately suspend and wait until it is cleared. + """ + + _steps: dict[Thread, Step] + """Ongoing steps, keyed by thread.""" + + def __init__(self): + self.log = log + self.exception_break_mode = ExceptionBreakMode.NEVER + self._stopped_by = None + self._steps = {} + + # sys.monitoring callbacks may be invoked during finalization, in which case + # they need access to these identifiers to detect that and abort early. However, + # module globals are removed during finalization, so we need to preload these + # into member variables to ensure they are still accessible. + self.sys = sys + self.DISABLE = monitoring.DISABLE + + @property + def adapter(self): + return server.adapter() + + def start(self): + """ + Register sys.monitoring tracing callbacks. + """ + + log.info("Registering sys.monitoring tracing callbacks...") + + monitoring.use_tool_id(monitoring.DEBUGGER_ID, "debugpy") + monitoring.set_events( + monitoring.DEBUGGER_ID, + ( + monitoring.events.LINE + | monitoring.events.PY_START + | monitoring.events.PY_RETURN + | monitoring.events.PY_RESUME + | monitoring.events.PY_YIELD + | monitoring.events.PY_THROW + | monitoring.events.PY_UNWIND + | monitoring.events.RAISE + | monitoring.events.RERAISE + | monitoring.events.EXCEPTION_HANDLED + ), + ) + trace_funcs = { + monitoring.events.LINE: self._trace_line, + monitoring.events.PY_START: self._trace_py_start, + monitoring.events.PY_RESUME: self._trace_py_resume, + monitoring.events.PY_RETURN: self._trace_py_return, + monitoring.events.PY_YIELD: self._trace_py_yield, + monitoring.events.PY_THROW: self._trace_py_throw, + monitoring.events.PY_UNWIND: self._trace_py_unwind, + monitoring.events.RAISE: self._trace_raise, + monitoring.events.RERAISE: self._trace_reraise, + monitoring.events.EXCEPTION_HANDLED: self._trace_exception_handled, + } + for event, func in trace_funcs.items(): + monitoring.register_callback(monitoring.DEBUGGER_ID, event, func) + + self._old_sys_excepthook = sys.excepthook + sys.excepthook = self._sys_excepthook + + self._old_threading_excepthook = threading.excepthook + threading.excepthook = self._threading_excepthook + + log.info("sys.monitoring tracing callbacks registered.") + + def pause(self): + """ + Pause all threads. + """ + log.info("Pausing all threads.") + with _cvar: + # Although "pause" is a user-induced scenario that is not specifically + # associated with any thread, we still need to pick some thread that + # will nominally own it to report the event on. If there is a designated + # main thread in the process, use that, otherwise pick one at random. + python_thread = threading.main_thread() + if python_thread is None: + python_thread = next(iter(threading.enumerate()), None) + if python_thread is None: + raise ValueError("No threads to pause.") + thread = Thread.from_python_thread(python_thread) + self._begin_stop(thread, "pause") + + def resume(self): + """ + Resume all threads. + """ + log.info("Resuming all threads.") + self._end_stop() + + def abandon_step(self, threads: Iterable[int] = None): + """ + Abandon any ongoing steps that are in progress on the specified threads + (all threads if argument is None). + """ + with _cvar: + if threads is None: + step = self._steps.clear() + while self._steps: + thread, step = self._steps.popitem() + log.info(f"Abandoned {step} on {thread}.") + else: + for thread in threads: + step = self._steps.pop(thread, None) + if step is not None: + log.info(f"Abandoned {step} on {thread}.") + _cvar.notify_all() + monitoring.restart_events() + + def step_in(self, thread: Thread): + """ + Step into the next statement executed by the specified thread. + """ + log.info(f"Step in on {thread}.") + with _cvar: + self._steps[thread] = StepIn() + self._end_stop() + monitoring.restart_events() + + def step_out(self, thread: Thread): + """ + Step out of the current function executed by the specified thread. + """ + log.info(f"Step out on {thread}.") + with _cvar: + self._steps[thread] = StepOut() + self._end_stop() + monitoring.restart_events() + + def step_over(self, thread: Thread): + log.info(f"Step over on {thread}.") + """ + Step over the next statement executed by the specified thread. + """ + with _cvar: + self._steps[thread] = StepOver() + self._end_stop() + monitoring.restart_events() + + def goto( + self, thread: Thread, source: Source, line: int, finish_callback: FunctionType + ): + log.info(f"Goto {source}:{line} on {thread}") + """ + Change the instruction pointer of the current thread to point to + the new line/source file. + """ + + def goto_handler(): + # Filter out runtime warnings that come from doing a goto + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + try: + thread.current_frame.f_lineno = line + except ValueError as e: + finish_callback(e) + else: + finish_callback(None) + + # Send a stop once we finish changing the line number + self._begin_stop(thread, "goto") + + + with _cvar: + # We do this with a callback because only the trace function + # can change the lineno + thread.pending_callback = goto_handler + # Notify just this thread + _cvar.notify(thread.id) + + def _begin_stop( + self, + thread: Thread, + reason: str, + hit_breakpoints: Iterable[Breakpoint] = (), + ): + """ + Report the stop to the adapter and tell all threads to suspend themselves. + """ + + with _cvar: + self._stopped_by = thread + _cvar.notify_all() + monitoring.restart_events() + self.adapter.channel.send_event( + "stopped", + { + "reason": reason, + "threadId": thread.id, + "allThreadsStopped": True, + "hitBreakpointIds": [bp.id for bp in hit_breakpoints], + }, + ) + + def _end_stop(self): + """ + Tell all threads to resume themselves. + """ + with _cvar: + self._stopped_by = None + _cvar.notify_all() + + def _this_thread(self) -> Thread | None: + """ + Returns the DAP Thread object for the current thread, or None if interpreter + is shutting down. + """ + return ( + None + if self.sys.is_finalizing() + else Thread.from_python_thread(threading.current_thread()) + ) + + def _suspend_this_thread(self, python_frame: FrameType): + """ + Suspends execution of this thread until the current stop ends. + """ + + thread: Thread | None = self._this_thread() + with _cvar: + if self._stopped_by is None: + return + + log.info(f"{thread} suspended.") + thread.current_frame = python_frame + while self._stopped_by is not None: + _cvar.wait() + + # This thread may need to run some code while it's still + # stopped. + if thread.pending_callback is not None: + thread.pending_callback() + thread.pending_callback = None + + thread.current_frame = None + log.info(f"{thread} resumed.") + StackFrame.invalidate(thread) + gc.collect() + + step = self._steps.get(thread, None) + if step is not None and step.origin is None: + # This step has just begun - update the Step object with information + # about current frame that will be used to track step completion. + step.origin = python_frame + step.origin_line = python_frame.f_lineno + + def _trace_line(self, code: CodeType, line_number: int): + thread = self._this_thread() + if thread is None or not thread.is_traced: + return self.DISABLE + + log.debug(f"sys.monitoring event: LINE({line_number}, {code})") + + # These two local variables hold direct or indirect references to frame + # objects on the stack of the current thread, and thus must be cleaned up + # on exit to avoid expensive GC cycles. + python_frame = inspect.currentframe().f_back + frame = None + try: + with _cvar: + step = self._steps.get(thread, None) + is_stepping = step is not None and step.origin is not None + if not is_stepping: + log.debug(f"No step in progress on {thread}.") + else: + log.debug( + f"Tracing {step} originating from {step.origin} on {thread}." + ) + if step.is_complete(python_frame): + log.info(f"{step} finished on thread {thread}.") + del self._steps[thread] + self._begin_stop(thread, "step") + + if self._stopped_by is not None: + # Even if this thread is suspending, any debugpy internal code on it should + # keep running until it returns to user code; otherwise, it may deadlock + # if it was holding e.g. a messaging lock. + if not is_internal_python_frame(python_frame): + self._suspend_this_thread(python_frame) + return + + log.debug(f"Resolving path {code.co_filename!r}...") + source = Source(code.co_filename) + log.debug(f"Path {code.co_filename!r} resolved to {source}.") + + bps = Breakpoint.at(source, line_number) + if not bps and not is_stepping: + log.debug(f"No breakpoints at {source}:{line_number}.") + return self.DISABLE + log.debug( + f"Considering breakpoints: {[bp.__getstate__() for bp in bps]}." + ) + + frame = StackFrame(thread, python_frame) + stop_bps = [] + for bp in bps: + match bp.is_triggered(frame): + case str() as message: + # Triggered, has logMessage - print it but don't stop. + self.adapter.channel.send_event( + "output", + { + "category": "console", + "output": message, + "line": line_number, + "source": source, + }, + ) + case triggered if triggered: + # Triggered, no logMessage - stop. + stop_bps.append(bp) + case _: + continue + + if stop_bps: + log.info( + f"Stack frame {frame} stopping at breakpoints {[bp.__getstate__() for bp in stop_bps]}." + ) + self._begin_stop(thread, "breakpoint", stop_bps) + self._suspend_this_thread(frame.python_frame) + finally: + del frame + del python_frame + + def _process_exception( + self, + exc: BaseException, + thread: Thread, + origin: Literal["raise", "reraise", "excepthook"], + ): + if isinstance(exc, UnhandledException): + exc = exc.exception + origin = "excepthook" + + # These two local variables hold direct or indirect references to frame + # objects on the stack of the current thread, and thus must be cleaned up + # on exit to avoid expensive GC cycles. + python_frame = inspect.currentframe().f_back + frame = None + try: + stop = False + match self.exception_break_mode: + case ExceptionBreakMode.ALWAYS: + stop = origin == "raise" + case ExceptionBreakMode.UNHANDLED: + stop = origin == "excepthook" + if stop: + # The stack trace is already unwound, and reporting it as empty + # would be useless. Instead, we want to report it as t was at the + # point where the exception was raised, so walk the traceback all + # the way back to the originating frame. + if exc.__traceback__ is not None: + for python_frame, _ in traceback.walk_tb(exc.__traceback__): + pass + + if stop: + thread.current_exception = ExceptionInfo(exc, self.exception_break_mode) + self._begin_stop(thread, "exception") + self._suspend_this_thread(python_frame) + thread.current_exception = None + + finally: + del frame + del python_frame + + def _trace_raise(self, code: CodeType, ip: int, exc: BaseException): + if isinstance(exc, self.CONTROL_FLOW_EXCEPTIONS): + return + thread = self._this_thread() + if thread is None or not thread.is_traced: + return + log.debug( + f"sys.monitoring event: RAISE({code}, {ip}, {type(exc).__qualname__})" + ) + self._process_exception(exc, thread, "raise") + + def _trace_reraise(self, code: CodeType, ip: int, exc: BaseException): + if isinstance(exc, self.CONTROL_FLOW_EXCEPTIONS): + return + thread = self._this_thread() + if thread is None or not thread.is_traced: + return + log.debug( + f"sys.monitoring event: RERAISE({code}, {ip}, {type(exc).__qualname__})" + ) + self._process_exception(exc, thread, "reraise") + + def _sys_excepthook(self, exc_type: type, exc: BaseException, tb: TracebackType): + thread = self._this_thread() + if thread is None or not thread.is_traced: + return + log.debug(f"sys.excepthook({exc_type}, {exc})") + try: + # delegate to _trace_raise + raise UnhandledException(exc) + except: + pass + return self._old_sys_excepthook(exc_type, exc, tb) + + def _threading_excepthook(self, args): + thread = self._this_thread() + if thread is None or not thread.is_traced: + return + exc_type = args.exc_type + exc = args.exc_value + log.debug(f"threading.excepthook({exc_type}, {exc})") + try: + # delegate to _trace_raise + raise UnhandledException(exc) + except: + pass + return self._old_threading_excepthook(args) + + def _trace_py_start(self, code: CodeType, ip: int): + thread = self._this_thread() + if thread is None or not thread.is_traced: + return self.DISABLE + log.debug(f"sys.monitoring event: PY_START({code}, {ip})") + + def _trace_py_resume(self, code: CodeType, ip: int): + thread = self._this_thread() + if thread is None or not thread.is_traced: + return self.DISABLE + log.debug(f"sys.monitoring event: PY_RESUME({code}, {ip})") + + def _trace_py_return(self, code: CodeType, ip: int, retval: object): + thread = self._this_thread() + if thread is None or not thread.is_traced: + return self.DISABLE + log.debug(f"sys.monitoring event: PY_RETURN({code}, {ip})") + # TODO: capture returned value to report it when client requests locals. + pass + + def _trace_py_yield(self, code: CodeType, ip: int, retval: object): + thread = self._this_thread() + if thread is None or not thread.is_traced: + return self.DISABLE + log.debug(f"sys.monitoring event: PY_YIELD({code}, {ip})") + # TODO: capture yielded value to report it when client requests locals. + pass + + def _trace_py_throw(self, code: CodeType, ip: int, exc: BaseException): + thread = self._this_thread() + if thread is None or not thread.is_traced: + return + log.debug( + f"sys.monitoring event: PY_THROW({code}, {ip}, {type(exc).__qualname__})" + ) + + def _trace_py_unwind(self, code: CodeType, ip: int, exc: BaseException): + thread = self._this_thread() + if thread is None or not thread.is_traced: + return + log.debug( + f"sys.monitoring event: PY_UNWIND({code}, {ip}, {type(exc).__qualname__})" + ) + + def _trace_exception_handled(self, code: CodeType, ip: int, exc: BaseException): + thread = self._this_thread() + if thread is None or not thread.is_traced: + return + log.debug( + f"sys.monitoring event: EXCEPTION_HANDLED({code}, {ip}, {type(exc).__qualname__})" + ) + + +tracer = Tracer() +del Tracer diff --git a/tests/debugpy/server/inspect/__init__.py b/tests/debugpy/server/inspect/__init__.py new file mode 100644 index 000000000..9ff2c2b73 --- /dev/null +++ b/tests/debugpy/server/inspect/__init__.py @@ -0,0 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +"""Tests for debugpy.server.inspect""" diff --git a/tests/debugpy/server/inspect/test_numeric.py b/tests/debugpy/server/inspect/test_numeric.py new file mode 100644 index 000000000..9d68e5322 --- /dev/null +++ b/tests/debugpy/server/inspect/test_numeric.py @@ -0,0 +1,36 @@ +import pytest +from debugpy.server.inspect import ValueFormat +from debugpy.server.inspect.children import NamedChildObject, inspect_children +from debugpy.server.inspect.repr import formatted_repr + + +@pytest.mark.parametrize("base", ["dec", "hex"]) +@pytest.mark.parametrize("value", [0, 42, -42, 1_234_567_890_123, -1_234_567_890_123]) +def test_int_repr(value, base): + format = ValueFormat(hex=(base == "hex")) + expected_repr = (hex if base == "hex" else repr)(value) + assert formatted_repr(value, format) == expected_repr + + +def test_int_repr_derived(): + class CustomInt(int): + def __repr__(self): + return f"CustomInt({int(self)})" + + value = CustomInt(42) + assert formatted_repr(value, ValueFormat()) == repr(value) + + +def test_int_children(): + inspector = inspect_children(42) + + assert inspector.indexed_children_count() == 0 + assert list(inspector.indexed_children()) == [] + + assert inspector.named_children_count() == 4 + assert list(inspector.named_children()) == [ + NamedChildObject("denominator", 1), + NamedChildObject("imag", 0), + NamedChildObject("numerator", 42), + NamedChildObject("real", 42), + ] diff --git a/tests/debugpy/test_evaluate.py b/tests/debugpy/test_evaluate.py index 971844d5a..e12f8185d 100644 --- a/tests/debugpy/test_evaluate.py +++ b/tests/debugpy/test_evaluate.py @@ -4,6 +4,7 @@ import pytest +from debugpy.common.messaging import InvalidMessageError from tests import debug, timeline from tests.patterns import some @@ -698,3 +699,80 @@ def finish(self): assert evaluate == some.dict.containing({"result": "None"}) session.request_continue() + + +def test_variable_paging(pyfile, target, run): + @pyfile + def code_to_debug(): + import debuggee + + debuggee.setup() + xs = [i**2 for i in range(10)] + print(xs) # @bp + + with debug.Session() as session: + with run(session, target(code_to_debug)): + session.set_breakpoints(code_to_debug, all) + + stop = session.wait_for_stop() + + evaluate = session.request( + "evaluate", {"expression": "xs", "frameId": stop.frame_id} + ) + assert evaluate == some.dict.containing( + { + "type": "list", + "namedVariables": 1, + "indexedVariables": 10, + "variablesReference": some.int, + } + ) + + expected = [ + some.dict.containing( + { + "name": "len()", + "type": "int", + "value": repr(10), + } + ) + ] + [ + some.dict.containing( + { + "name": f"[{i}]", + "type": "int", + "value": repr(i**2), + } + ) + for i in range(10) + ] + + def request_children(*, start=None, count=None): + body = {"variablesReference": evaluate["variablesReference"]} + if start is not None: + body["start"] = start + if count is not None: + body["count"] = count + return session.request("variables", body)["variables"] + + # Slice from start. + children = request_children(count=3) + assert children == expected[:3] + + # Slice to end. + children = request_children(start=7) + assert children == expected[7:] + + # Slice in the middle. + children = request_children(start=4, count=2) + assert children == expected[4 : 4 + 2] + + # Slice past end. + children = request_children(start=8, count=10) + assert children == expected[8:] + + # Invalid start. + with pytest.raises(InvalidMessageError): + request_children(start=-1) + + session.request_continue() diff --git a/tests/debugpy/test_step.py b/tests/debugpy/test_step.py index 99064c053..4a20eb4f1 100644 --- a/tests/debugpy/test_step.py +++ b/tests/debugpy/test_step.py @@ -56,7 +56,6 @@ def func(): inner2_target = targets[0]["id"] session.request("goto", {"threadId": stop.thread_id, "targetId": inner2_target}) - session.wait_for_next_event("continued") stop = session.wait_for_stop( "goto", expected_frames=[some.dap.frame(code_to_debug, "inner2")] diff --git a/tests/patterns/_impl.py b/tests/patterns/_impl.py index de7d0b51b..081e3e4d6 100644 --- a/tests/patterns/_impl.py +++ b/tests/patterns/_impl.py @@ -8,12 +8,12 @@ import collections import itertools +import pathlib import py.path import re import sys from debugpy.common import util -import pydevd_file_utils class Some(object): @@ -183,8 +183,8 @@ def matches(self, other): else: return NotImplemented - left = pydevd_file_utils.get_path_with_real_case(self.path) - right = pydevd_file_utils.get_path_with_real_case(other) + left = pathlib.Path(self.path).resolve() + right = pathlib.Path(other).resolve() return left == right diff --git a/tests/patterns/dap.py b/tests/patterns/dap.py index 21a0acaf2..d83c737ff 100644 --- a/tests/patterns/dap.py +++ b/tests/patterns/dap.py @@ -11,7 +11,7 @@ from tests.patterns import some, _impl -id = some.int.in_range(0, 10000) +id = some.int.in_range(-10000, 10000) """Matches a DAP "id", assuming some reasonable range for an implementation that generates those ids sequentially. """