From 8447a153967a2e2f4bb1dfe1eb213e68e56fe07a Mon Sep 17 00:00:00 2001 From: Pavel Minaev Date: Sun, 9 Feb 2020 22:43:28 -0800 Subject: [PATCH] Refactor debugpy API and CLI for clarity and consistency. --- README.md | 98 +++----- src/debugpy/__init__.py | 193 +++++++++------ src/debugpy/_vendored/force_pydevd.py | 2 +- src/debugpy/adapter/__main__.py | 2 +- src/debugpy/adapter/ide.py | 4 +- src/debugpy/adapter/launchers.py | 2 +- src/debugpy/adapter/servers.py | 13 +- src/debugpy/common/compat.py | 35 ++- src/debugpy/launcher/handlers.py | 13 +- src/debugpy/server/api.py | 274 +++++++++++----------- src/debugpy/server/attach_pid_injected.py | 53 +++-- src/debugpy/server/cli.py | 239 ++++++++++++------- tests/debug/runners.py | 24 +- tests/debug/session.py | 2 +- tests/debugpy/server/test_cli.py | 110 +++++---- tests/debugpy/test_attach.py | 56 ++--- tests/debugpy/test_breakpoints.py | 2 +- tests/debugpy/test_evaluate.py | 4 +- tests/debugpy/test_log.py | 2 +- tests/debugpy/test_run.py | 2 +- tests/debugpy/test_system_info.py | 2 +- tests/debugpy/test_tracing.py | 19 +- 22 files changed, 630 insertions(+), 521 deletions(-) diff --git a/README.md b/README.md index f4f6ce92..62268c2f 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,23 @@ This debugger implements the Debug Adapter Protocol: [debugProtocol.json](https: ## `debugpy` CLI Usage ### Debugging a script file -To run a script file with debugging enabled, but without waiting for the IDE to attach (i.e. code starts executing immediately): +To run a script file with debugging enabled, but without waiting for the client to attach (i.e. code starts executing immediately): ```console --m debugpy --host localhost --port 5678 myfile.py +-m debugpy --listen localhost:5678 myfile.py ``` -To wait until the IDE attaches before running your code, use the `--wait` switch. +To wait until the client attaches before running your code, use the `--wait-for-client` switch. ```console --m debugpy --host localhost --port 5678 --wait myfile.py +-m debugpy --listen localhost:5678 --wait-for-client myfile.py ``` -The `--host` option specifies the interface on which the debug server is listening for connections. To be able to attach from another machine, make sure that the server is listening on a public interface - using `0.0.0.0` will make it listen on all available interfaces: +The hostname passed to `--listen` specifies the interface on which the debug adapter will be listening for connections from DAP clients. It can be omitted, with only the port number specified: ```console --m debugpy --host 0.0.0.0 --port 5678 myfile.py +-m debugpy --listen 5678 ... +``` +in which case the default interface is 127.0.0.1. + +To be able to attach from another machine, make sure that the adapter is listening on a public interface - using `0.0.0.0` will make it listen on all available interfaces: +```console +-m debugpy --listen 0.0.0.0:5678 myfile.py ``` This should only be done on secure networks, since anyone who can connect to the specified port can then execute arbitrary code within the debugged process. @@ -36,100 +42,64 @@ To pass arguments to the script, just specify them after the filename. This work ### Debugging a module To run a module, use the `-m` switch instead of filename: ```console --m debugpy --host localhost --port 5678 -m mymodule +-m debugpy --listen localhost:5678 -m mymodule ``` -Same as with scripts, command line arguments can be passed to the module by specifying them after the module name. All other debugpy switches work identically in this mode; in particular, `--wait` can be used to block execution until the IDE attaches. +Same as with scripts, command line arguments can be passed to the module by specifying them after the module name. All other debugpy switches work identically in this mode; in particular, `--wait-for-client` can be used to block execution until the client attaches. ### Attaching to a running process by ID The following command injects the debugger into a process with a given PID that is running Python code. Once the command returns, a debugpy server is running within the process, as if that process was launched via `-m debugpy` itself. ```console --m debugpy --host localhost --port 5678 --pid 12345 +-m debugpy --listen localhost:5678 --pid 12345 ``` ## `debugpy` Import usage ### Enabling debugging -At the beginning of your script, import debugpy, and call `debugpy.enable_attach()` to start the debug server. The default hostname is `0.0.0.0`, and the default port is 5678; these can be overridden by passing a `(host, port)` tuple as the first argument of `enable_attach()`. +At the beginning of your script, import debugpy, and call `debugpy.listen()` to start the debug adapter, passing a `(host, port)` tuple as the first argument. ```python import debugpy -debugpy.enable_attach() +debugpy.listen(("localhost", 5678)) +... +``` +As with the `--listen` command line switch, hostname can be omitted, and defaults to `"127.0.0.1"`: +```python +debugpy.listen(5678) ... ``` -### Waiting for the IDE to attach -Use the `debugpy.wait_for_attach()` function to block program execution until the IDE is attached. +### Waiting for the client to attach +Use the `debugpy.wait_for_client()` function to block program execution until the client is attached. ```python import debugpy -debugpy.enable_attach() -debugpy.wait_for_attach() # blocks execution until IDE is attached +debugpy.listen(5678) +debugpy.wait_for_client() # blocks execution until client is attached ... ``` ### `breakpoint()` function -In Python 3.7 and above, `debugpy` supports the standard `breakpoint()` function. Use `debugpy.break_into_debugger()` function for similar behavior and compatibility with older versions of Python (3.6 and below). If the debugger is attached when either of these functions is invoked, it will pause execution on the calling line, as if it had a breakpoint set. If there's no IDE attached, the functions do nothing, and the code continues to execute normally. +In Python 3.7 and above, `debugpy` supports the standard `breakpoint()` function. Use `debugpy.breakpoint()` function for similar behavior and compatibility with older versions of Python. If the debugger is attached when either of these functions is invoked, it will pause execution on the calling line, as if it had a breakpoint set. If there's no client attached, the functions do nothing, and the code continues to execute normally. ```python import debugpy -debugpy.enable_attach() +debugpy.listen(...) while True: ... - breakpoint() # or debugpy.break_into_debugger() on <3.7 + breakpoint() # or debugpy.breakpoint() on 3.6 and below ... ``` -## Custom Protocol arguments -### Launch request arguments -```json5 -{ - "debugOptions": [ - "RedirectOutput", // Whether to redirect stdout and stderr (see pydevd_comm.CMD_REDIRECT_OUTPUT) - "WaitOnNormalExit", // Wait for user input after user code exits normally - "WaitOnAbnormalExit", // Wait for user input after user code exits with error - "Django", // Enables Django Template debugging - "Jinja", // Enables Jinja (Flask) Template debugging - "FixFilePathCase", // See FIX_FILE_PATH_CASE in wrapper.py - "DebugStdLib", // Whether to enable debugging of standard library functions - "StopOnEntry", // Whether to stop at first line of user code - "ShowReturnValue", // Show return values of functions - ] -} -``` - -### Attach request arguments -```json5 -{ - "debugOptions": [ - "RedirectOutput", // Whether to redirect stdout and stderr (see pydevd_comm.CMD_REDIRECT_OUTPUT) - "Django", // Enables Django Template debugging - "Jinja", // Enables Jinja (Flask) Template debugging - "FixFilePathCase", // See FIX_FILE_PATH_CASE in wrapper.py - "DebugStdLib", // Whether to enable debugging of standard library functions - "WindowsClient", // Whether client OS is Windows - "UnixClient", // Whether client OS is Unix - "ShowReturnValue", // Show return values of functions - ], - "pathMappings": [ - { - "localRoot": "C:\\Project\\src", // Local root (where the IDE is running) - "remoteRoot": "/home/smith/proj" // Remote root (where remote code is running) - }, - // Add more path mappings - ] -} -``` - ## Debugger logging -To enable debugger internal logging via CLI, the `--log-dir` switch can be used: +To enable debugger internal logging via CLI, the `--log-to` switch can be used: ```console --m debugpy --log-dir path/to/logs ... +-m debugpy --log-to path/to/logs ... ``` -When using `enable_attach`, the same can be done with `log_dir` argument: +When using the API, the same can be done with `debugpy.log_to()`: ```py -debugpy.enable_attach(log_dir='path/to/logs') +debugpy.log_to('path/to/logs') +debugpy.listen(...) ``` In both cases, the environment variable `DEBUGPY_LOG_DIR` can also be set to the same effect. When logging is enabled, debugpy will create several log files with names matching `debugpy*.log` in the specified directory, corresponding to different components of the debugger. When subprocess debugging is enabled, separate logs are created for every subprocess. - diff --git a/src/debugpy/__init__.py b/src/debugpy/__init__.py index 3dd43cbf..5a4cca43 100644 --- a/src/debugpy/__init__.py +++ b/src/debugpy/__init__.py @@ -9,21 +9,25 @@ from __future__ import absolute_import, division, print_function, unicode_litera https://microsoft.github.io/debug-adapter-protocol/ """ +# debugpy stable public API consists solely of members of this module that are +# enumerated below. __all__ = [ "__version__", - "attach", - "break_into_debugger", + "breakpoint", + "configure", "debug_this_thread", - "enable_attach", - "is_attached", - "wait_for_attach", - "tracing", + "is_client_connected", + "listen", + "log_to", + "trace_this_thread", + "wait_for_client", ] import codecs import os from debugpy import _version +from debugpy.common import compat # Expose debugpy.server API from subpackage, but do not actually import it unless @@ -34,106 +38,142 @@ from debugpy import _version # than 72 characters per line! - and must be readable when retrieved via help(). -def wait_for_attach(): - """If an IDE is connected to the debug server in this process, - returns immediately. Otherwise, blocks until an IDE connects. +def log_to(path): + """Generate detailed debugpy logs in the specified directory. - While this function is waiting, it can be canceled by calling - wait_for_attach.cancel(). + The directory must already exist. Several log files are generated, + one for every process involved in the debug session. """ from debugpy.server import api - return api.wait_for_attach() + return api.log_to(path) -def enable_attach(address, log_dir=None, multiprocess=True): - """Starts a DAP (Debug Adapter Protocol) server in this process, - listening for incoming socket connection from the IDE on the - specified address. +def configure(properties=None, **kwargs): + """Sets debug configuration properties that cannot be set in the + "attach" request, because they must be applied as early as possible + in the process being debugged. - address must be a (host, port) tuple, as defined by the standard - socket module for the AF_INET address family. + For example, a "launch" configuration with subprocess debugging + disabled can be defined entirely in JSON:: - If specified, log_dir must be a path to some existing directory; - the debugger will then create its log files in that directory. - Separate log files are created for every process, to accommodate - scenarios involving multiple processes. All generated log files - have names starting with "debugpy.", and extension ".log". + { + "request": "launch", + "subProcess": false, + ... + } - If multiprocess is true, debugpy will also intercept child processes - spawned by this process, inject a debug server into them, and - configure it to attach to the same IDE before the child process - starts running any user code. + But the same cannot be done with "attach", because "subProcess" + must be known at the point debugpy starts tracing execution. Thus, + it is not available in JSON, and must be omitted:: - Returns the interface and the port on which the debug server is + { + "request": "attach", + ... + } + + and set from within the debugged process instead:: + + debugpy.configure(subProcess=False) + debugpy.listen(...) + + Properties to set can be passed either as a single dict argument, + or as separate keyword arguments:: + + debugpy.configure({"subProcess": False}) + """ + pass + + +def listen(address): + """Starts a debug adapter debugging this process, that listens for + incoming socket connections from clients on the specified address. + + address must be either a (host, port) tuple, as defined by the + standard socket module for the AF_INET address family, or a port + number. If only the port is specified, host is "127.0.0.1". + + Returns the interface and the port on which the debug adapter is actually listening, in the same format as address. This may be different from address if port was 0 in the latter, in which case - the server will pick some unused ephemeral port to listen on. + the adapter will pick some unused ephemeral port to listen on. - This function does't wait for the IDE to connect to the debug server - that it starts. Use wait_for_attach() to block execution until the - IDE connects. + This function does't wait for a client to connect to the debug + adapter that it starts. Use wait_for_client() to block execution + until the client connects. """ from debugpy.server import api - return api.enable_attach(address, log_dir) + return api.listen(address) -def attach(address, log_dir=None, multiprocess=True): - """Starts a DAP (Debug Adapter Protocol) server in this process, - and connects it to the IDE that is listening for an incoming - connection on a socket with the specified address. +@compat.kwonly +def connect(address, access_token=None): + """Tells an existing debug adapter instance that is listening on the + specified address to debug this process. - address must be a (host, port) tuple, as defined by the standard - socket module for the AF_INET address family. + address must be either a (host, port) tuple, as defined by the + standard socket module for the AF_INET address family, or a port + number. If only the port is specified, host is "127.0.0.1". - If specified, log_dir must be a path to some existing directory; - the debugger will then create its log files in that directory. - Separate log files are created for every process, to accommodate - scenarios involving multiple processes. All generated log files - have names starting with "debugpy.", and extension ".log". + access_token must be the same value that was passed to the adapter + via the --server-access-token command-line switch. - If multiprocess is true, debugpy will also intercept child processes - spawned by this process, inject a debug server into them, and - configure it to attach to the same IDE before the child process - starts running any user code. - - This function doesn't return until connection to the IDE has been - established. + This function does't wait for a client to connect to the debug + adapter that it connects to. Use wait_for_client() to block + execution until the client connects. """ from debugpy.server import api - return api.attach(address, log_dir) + return api.connect(address, access_token=access_token) -def is_attached(): - """True if an IDE is connected to the debug server in this process. +def wait_for_client(): + """If there is a client connected to the debug adapter that is + debugging this process, returns immediately. Otherwise, blocks + until a client connects to the adapter. + + While this function is waiting, it can be canceled by calling + wait_for_client.cancel() from another thread. """ from debugpy.server import api - return api.is_attached() + return api.wait_for_client() -def break_into_debugger(): - """If the IDE is connected, pauses execution of all threads, and - breaks into the debugger with current thread as active. +def is_client_connected(): + """True if a client is connected to the debug adapter that is + debugging this process. """ from debugpy.server import api - return api.break_into_debugger() + return api.is_client_connected() + + +def breakpoint(): + """If a client is connected to the debug adapter that is debugging + this process, pauses execution of all threads, and simulates a + breakpoint being hit at the line following the call. + + On Python 3.7 and above, this is the same as builtins.breakpoint(). + """ + + from debugpy.server import api + + return api.breakpoint() def debug_this_thread(): - """Tells debugpy to start tracing the current thread. + """Makes the debugger aware of the current thread. Must be called on any background thread that is started by means other than the usual Python APIs (i.e. the "threading" module), - for breakpoints to work on that thread. + in order for breakpoints to work on that thread. """ from debugpy.server import api @@ -141,26 +181,23 @@ def debug_this_thread(): return api.debug_this_thread() -def tracing(should_trace=None): - """Enables or disables tracing on this thread. When called without an - argument, returns the current tracing state. - When tracing is disabled, breakpoints will not be hit, but code executes - significantly faster. - If debugger is not attached, this function has no effect. - This function can also be used in a with-statement to automatically save - and then restore the previous tracing setting:: - with debugpy.tracing(False): - # Tracing disabled - ... - # Tracing restored - Parameters - ---------- - should_trace : bool, optional - Whether to enable or disable tracing. +def trace_this_thread(should_trace): + """Tells the debug adapter to enable or disable tracing on the + current thread. + + When the thread is traced, the debug adapter can detect breakpoints + being hit, but execution is slower, especially in functions that + have any breakpoints set in them. Disabling tracing when breakpoints + are not anticipated to be hit can improve performance. It can also + be used to skip breakpoints on a particular thread. + + Tracing is automatically disabled for all threads when there is no + client connected to the debug adapter. """ + from debugpy.server import api - return api.tracing(should_trace) + return api.trace_this_thread(should_trace) __version__ = _version.get_versions()["version"] diff --git a/src/debugpy/_vendored/force_pydevd.py b/src/debugpy/_vendored/force_pydevd.py index 86706b50..7f89532b 100644 --- a/src/debugpy/_vendored/force_pydevd.py +++ b/src/debugpy/_vendored/force_pydevd.py @@ -51,7 +51,7 @@ import debugpy # noqa def debugpy_breakpointhook(): - debugpy.break_into_debugger() + debugpy.breakpoint() pydevd.install_breakpointhook(debugpy_breakpointhook) diff --git a/src/debugpy/adapter/__main__.py b/src/debugpy/adapter/__main__.py index dcec14f8..ed613344 100644 --- a/src/debugpy/adapter/__main__.py +++ b/src/debugpy/adapter/__main__.py @@ -66,7 +66,7 @@ def main(args): "error": "Can't listen for IDE connections: " + str(exc) } else: - endpoints["ide"] = {"host": ide_host, "port": ide_port} + endpoints["client"] = {"host": ide_host, "port": ide_port} if args.for_server is not None: log.info( diff --git a/src/debugpy/adapter/ide.py b/src/debugpy/adapter/ide.py index 4ae73e3a..2ff05173 100644 --- a/src/debugpy/adapter/ide.py +++ b/src/debugpy/adapter/ide.py @@ -304,12 +304,12 @@ class IDE(components.Component): # connected already, and thus the wait timeout is zero. # # If neither is specified, and "waitForAttach" is true, this is attach-by-socket - # with the server expected to connect to the adapter via debugpy.attach(). There + # with the server expected to connect to the adapter via debugpy.connect(). There # is no PID known in advance, so just wait until the first server connection # indefinitely, with no timeout. # # If neither is specified, and "waitForAttach" is false, this is attach-by-socket - # in which the server has spawned the adapter via debugpy.enable_attach(). There + # in which the server has spawned the adapter via debugpy.listen(). There # is no PID known to the IDE in advance, but the server connection should be # either be there already, or the server should be connecting shortly, so there # must be a timeout. diff --git a/src/debugpy/adapter/launchers.py b/src/debugpy/adapter/launchers.py index 991e398d..0701e40c 100644 --- a/src/debugpy/adapter/launchers.py +++ b/src/debugpy/adapter/launchers.py @@ -74,7 +74,7 @@ def spawn_debuggee(session, start_request, sudo, args, console, console_title): arguments = dict(start_request.arguments) if not session.no_debug: _, arguments["port"] = servers.listener.getsockname() - arguments["clientAccessToken"] = adapter.access_token + arguments["adapterAccessToken"] = adapter.access_token def on_launcher_connected(sock): listener.close() diff --git a/src/debugpy/adapter/servers.py b/src/debugpy/adapter/servers.py index 493d7d15..7f5284e4 100644 --- a/src/debugpy/adapter/servers.py +++ b/src/debugpy/adapter/servers.py @@ -99,7 +99,9 @@ if 'debugpy' not in sys.modules: # Failure to inject is not a fatal error - such a subprocess can # still be debugged, it just won't support "import debugpy" in user # code - so don't terminate the session. - log.exception("Failed to inject debugpy into {0}:", self, level="warning") + log.exception( + "Failed to inject debugpy into {0}:", self, level="warning" + ) with _lock: # The server can disconnect concurrently before we get here, e.g. if @@ -422,14 +424,11 @@ def inject(pid, debugpy_args): cmdline = [ sys.executable, compat.filename(os.path.dirname(debugpy.__file__)), - "--client", - "--host", - host, - "--port", - str(port), + "--connect", + host + ":" + str(port), ] if adapter.access_token is not None: - cmdline += ["--client-access-token", adapter.access_token] + cmdline += ["--adapter-access-token", adapter.access_token] cmdline += debugpy_args cmdline += ["--pid", str(pid)] diff --git a/src/debugpy/common/compat.py b/src/debugpy/common/compat.py index 3beadc27..d836aee2 100644 --- a/src/debugpy/common/compat.py +++ b/src/debugpy/common/compat.py @@ -1,5 +1,5 @@ # Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See LICENSE in the project root +# # Licensed under the MIT License. See LICENSE in the project root # for license information. from __future__ import absolute_import, division, print_function, unicode_literals @@ -7,6 +7,7 @@ from __future__ import absolute_import, division, print_function, unicode_litera """Python 2/3 compatibility helpers. """ +import functools import inspect import itertools import sys @@ -174,3 +175,35 @@ def srcnameof(obj): name += ")" return name + + +def kwonly(f): + """Makes all arguments with default values keyword-only. + + If the default value is kwonly.required, then the argument must be specified. + """ + + arg_names, args_name, kwargs_name, arg_defaults = inspect.getargspec(f) + assert args_name is None and kwargs_name is None + argc = len(arg_names) + pos_argc = argc - len(arg_defaults) + required_names = { + name + for name, val in zip(arg_names[pos_argc:], arg_defaults) + if val is kwonly.required + } + + @functools.wraps(f) + def kwonly_f(*args, **kwargs): + if len(args) > pos_argc: + raise TypeError("too many positional arguments") + if not required_names.issubset(kwargs): + missing_names = required_names.difference(kwargs) + missing_names = ", ".join(repr(s) for s in missing_names) + raise TypeError("missing required keyword-only arguments: " + missing_names) + return f(*args, **kwargs) + + return kwonly_f + + +kwonly.required = object() diff --git a/src/debugpy/launcher/handlers.py b/src/debugpy/launcher/handlers.py index 666582fe..2544e10f 100644 --- a/src/debugpy/launcher/handlers.py +++ b/src/debugpy/launcher/handlers.py @@ -64,15 +64,12 @@ def launch_request(request): port = request("port", int) cmdline += [ compat.filename(os.path.dirname(debugpy.__file__)), - "--client", - "--host", - "127.0.0.1", - "--port", + "--connect", str(port), ] - client_access_token = request("clientAccessToken", unicode, optional=True) - if client_access_token != (): - cmdline += ["--client-access-token", compat.filename(client_access_token)] + adapter_access_token = request("adapterAccessToken", unicode, optional=True) + if adapter_access_token != (): + cmdline += ["--adapter-access-token", compat.filename(adapter_access_token)] debugpy_args = request("debugpyArgs", json.array(unicode)) cmdline += debugpy_args @@ -113,7 +110,7 @@ def launch_request(request): if sys.platform == "win32": # Environment variables are case-insensitive on Win32, so we need to normalize # both dicts to make sure that env vars specified in the debug configuration - # overwrite the global env vars correctly. If debug config has entries that + # overwrite the global env vars correctly. If debug config has entries that # differ in case only, that's an error. env = {k.upper(): v for k, v in os.environ.items()} n = len(env_changes) diff --git a/src/debugpy/server/api.py b/src/debugpy/server/api.py index edb3dbe9..cff8493a 100644 --- a/src/debugpy/server/api.py +++ b/src/debugpy/server/api.py @@ -5,7 +5,6 @@ from __future__ import absolute_import, division, print_function, unicode_literals import codecs -import contextlib import json import os import pydevd @@ -15,62 +14,112 @@ import threading import debugpy from debugpy import adapter -from debugpy.common import compat, log, sockets -from debugpy.server import options +from debugpy.common import compat, fmt, log, sockets from _pydevd_bundle.pydevd_constants import get_global_debugger from pydevd_file_utils import get_abs_path_real_path_and_base_from_file +_tls = threading.local() + +# TODO: "gevent", if possible. +_config = {"subProcess": True} + +# This must be a global to prevent it from being garbage collected and triggering +# https://bugs.python.org/issue37380. +_adapter_process = None + + def _settrace(*args, **kwargs): log.debug("pydevd.settrace(*{0!r}, **{1!r})", args, kwargs) - return pydevd.settrace(*args, **kwargs) + try: + return pydevd.settrace(*args, **kwargs) + except Exception: + raise + else: + _settrace.called = True -def wait_for_attach(): - log.debug("wait_for_attach()") - dbg = get_global_debugger() - if dbg is None: - raise RuntimeError("wait_for_attach() called before enable_attach()") +_settrace.called = False - cancel_event = threading.Event() - debugpy.wait_for_attach.cancel = wait_for_attach.cancel = cancel_event.set - pydevd._wait_for_attach(cancel=cancel_event) + +def ensure_logging(): + """Starts logging to log.log_dir, if it hasn't already been done. + """ + if ensure_logging.ensured: + return + ensure_logging.ensured = True + log.to_file(prefix="debugpy.server") + log.describe_environment("Initial environment:") + + +ensure_logging.ensured = False + + +def log_to(path): + if ensure_logging.ensured: + raise RuntimeError("logging has already begun") + + log.debug("log_to{0!r}", (path,)) + if path is sys.stderr: + log.stderr.levels |= set(log.LEVELS) + else: + log.log_dir = path + + +def configure(properties, **kwargs): + if _settrace.called: + raise RuntimeError("debug adapter is already running") + + ensure_logging() + log.debug("configure{0!r}", (properties, kwargs)) + + if properties is None: + properties = kwargs + else: + properties = dict(properties) + properties.update(kwargs) + + for k, v in properties.items(): + if k not in _config: + raise ValueError(fmt("Unknown property {0!r}", k)) + expected_type = type(_config[k]) + if type(v) is not expected_type: + raise ValueError(fmt("{0!r} must be a {1}", k, expected_type.__name__)) + _config[k] = v def _starts_debugging(func): - def debug(address, log_dir=None, multiprocess=True): - if log_dir: - log.log_dir = log_dir + def debug(address, **kwargs): + if _settrace.called: + raise RuntimeError("this process already has a debug adapter") - log.to_file(prefix="debugpy.server") - log.describe_environment("debugpy.server debug start environment:") - log.debug("{0}{1!r}", func.__name__, (address, log_dir, multiprocess)) + try: + _, port = address + except Exception: + port = address + address = ("127.0.0.1", port) + try: + port.__index__() # ensure it's int-like + except Exception: + raise ValueError("expected port or (host, port)") + if not (0 <= port < 2 ** 16): + raise ValueError("invalid port number") - if is_attached(): - log.info("{0}() ignored - already attached.", func.__name__) - return options.host, options.port + ensure_logging() + log.debug("{0}({1!r}, **{2!r})", func.__name__, address, kwargs) - # Ensure port is int - if address is not options: - host, port = address - options.host, options.port = (host, int(port)) - - if multiprocess is not options: - options.multiprocess = multiprocess + settrace_kwargs = { + "suspend": False, + "patch_multiprocessing": _config.get("subProcess", True), + } debugpy_path, _, _ = get_abs_path_real_path_and_base_from_file(debugpy.__file__) debugpy_path = os.path.dirname(debugpy_path) - start_patterns = (debugpy_path,) - end_patterns = ("debugpy_launcher.py",) - log.info( - "Won't trace filenames starting with: {0!j}\n" - "Won't trace filenames ending with: {1!j}", - start_patterns, - end_patterns, - ) + settrace_kwargs["dont_trace_start_patterns"] = (debugpy_path,) + settrace_kwargs["dont_trace_end_patterns"] = ("debugpy_launcher.py",) try: - return func(start_patterns, end_patterns) + return func(address, settrace_kwargs, **kwargs) except Exception: raise log.exception("{0}() failed:", func.__name__, level="info") @@ -78,15 +127,12 @@ def _starts_debugging(func): @_starts_debugging -def enable_attach(dont_trace_start_patterns, dont_trace_end_patterns): +def listen(address, settrace_kwargs): # Errors below are logged with level="info", because the caller might be catching # and handling exceptions, and we don't want to spam their stderr unnecessarily. import subprocess - if hasattr(enable_attach, "adapter"): - raise AssertionError("enable_attach() can only be called once per process") - server_access_token = compat.force_str(codecs.encode(os.urandom(32), "hex")) try: @@ -99,21 +145,22 @@ def enable_attach(dont_trace_start_patterns, dont_trace_end_patterns): "Waiting for adapter endpoints on {0}:{1}...", endpoints_host, endpoints_port ) + host, port = address adapter_args = [ sys.executable, os.path.dirname(adapter.__file__), "--for-server", str(endpoints_port), "--host", - options.host, + host, "--port", - str(options.port), + str(port), "--server-access-token", server_access_token, ] if log.log_dir is not None: adapter_args += ["--log-dir", log.log_dir] - log.info("enable_attach() spawning adapter: {0!j}", adapter_args) + log.info("debugpy.listen() spawning adapter: {0!j}", adapter_args) # On Windows, detach the adapter from our console, if any, so that it doesn't # receive Ctrl+C from it, and doesn't keep it open once we exit. @@ -127,18 +174,19 @@ def enable_attach(dont_trace_start_patterns, dont_trace_end_patterns): # by holding a reference to it in a non-local variable, to avoid triggering # https://bugs.python.org/issue37380. try: - enable_attach.adapter = subprocess.Popen( + global _adapter_process + _adapter_process = subprocess.Popen( adapter_args, close_fds=True, creationflags=creationflags ) if os.name == "posix": # It's going to fork again to daemonize, so we need to wait on it to # clean it up properly. - enable_attach.adapter.wait() + _adapter_process.wait() else: # Suppress misleading warning about child process still being alive when # this process exits (https://bugs.python.org/issue38890). - enable_attach.adapter.returncode = 0 - pydevd.add_dont_terminate_child_pid(enable_attach.adapter.pid) + _adapter_process.returncode = 0 + pydevd.add_dont_terminate_child_pid(_adapter_process.pid) except Exception as exc: log.exception("Error spawning debug adapter:", level="info") raise RuntimeError("error spawning debug adapter: " + str(exc)) @@ -167,66 +215,69 @@ def enable_attach(dont_trace_start_patterns, dont_trace_end_patterns): raise RuntimeError(str(endpoints["error"])) try: - host = str(endpoints["server"]["host"]) - port = int(endpoints["server"]["port"]) - options.port = int(endpoints["ide"]["port"]) + server_host = str(endpoints["server"]["host"]) + server_port = int(endpoints["server"]["port"]) + client_host = str(endpoints["client"]["host"]) + client_port = int(endpoints["client"]["port"]) except Exception as exc: log.exception( "Error parsing adapter endpoints:\n{0!j}\n", endpoints, level="info" ) raise RuntimeError("error parsing adapter endpoints: " + str(exc)) log.info( - "Adapter is accepting incoming IDE connections on {0}:{1}", - options.host, - options.port, + "Adapter is accepting incoming client connections on {0}:{1}", + client_host, + client_port, ) _settrace( - host=host, - port=port, - suspend=False, - patch_multiprocessing=options.multiprocess, + host=server_host, + port=server_port, wait_for_ready_to_run=False, block_until_connected=True, - dont_trace_start_patterns=dont_trace_start_patterns, - dont_trace_end_patterns=dont_trace_end_patterns, access_token=server_access_token, - client_access_token=options.client_access_token, + **settrace_kwargs ) - log.info("pydevd is connected to adapter at {0}:{1}", host, port) - return options.host, options.port + log.info("pydevd is connected to adapter at {0}:{1}", server_host, server_port) + return client_host, client_port @_starts_debugging -def attach(dont_trace_start_patterns, dont_trace_end_patterns): - _settrace( - host=options.host, - port=options.port, - suspend=False, - patch_multiprocessing=options.multiprocess, - dont_trace_start_patterns=dont_trace_start_patterns, - dont_trace_end_patterns=dont_trace_end_patterns, - client_access_token=options.client_access_token, - ) +def connect(address, settrace_kwargs, access_token): + host, port = address + _settrace(host=host, port=port, client_access_token=access_token, **settrace_kwargs) -def is_attached(): +def wait_for_client(): + 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() + debugpy.wait_for_client.cancel = wait_for_client.cancel = cancel_event.set + pydevd._wait_for_attach(cancel=cancel_event) + + +def is_client_connected(): return pydevd._is_attached() -def break_into_debugger(): - log.debug("break_into_debugger()") - - if not is_attached(): - log.info("break_into_debugger() ignored - debugger not attached") +def breakpoint(): + ensure_logging() + if not is_client_connected(): + log.info("breakpoint() ignored - debugger not attached") return + log.debug("breakpoint()") # Get the first frame in the stack that's not an internal frame. - global_debugger = get_global_debugger() + pydb = get_global_debugger() stop_at_frame = sys._getframe().f_back while ( stop_at_frame is not None - and global_debugger.get_file_type(stop_at_frame) == global_debugger.PYDEV_FILE + and pydb.get_file_type(stop_at_frame) == pydb.PYDEV_FILE ): stop_at_frame = stop_at_frame.f_back @@ -240,63 +291,18 @@ def break_into_debugger(): def debug_this_thread(): + ensure_logging() log.debug("debug_this_thread()") + _settrace(suspend=False) -_tls = threading.local() +def trace_this_thread(should_trace): + ensure_logging() + log.debug("trace_this_thread({0!r})", should_trace) - -def tracing(should_trace): pydb = get_global_debugger() - - try: - was_tracing = _tls.is_tracing - except AttributeError: - was_tracing = pydb is not None - - if should_trace is None: - return was_tracing - - # It is possible that IDE attaches after tracing is changed, but before it is - # restored. In this case, we don't really want to restore the original value, - # because it will effectively disable tracing for the just-attached IDE. Doing - # the check outside the function below makes it so that if the original change - # was a no-op because IDE wasn't attached, restore will be no-op as well, even - # if IDE has attached by then. - - tid = threading.current_thread().ident - if pydb is None: - log.info("debugpy.tracing() ignored on thread {0} - debugger not attached", tid) - - def enable_or_disable(_): - # Always fetch the fresh value, in case it changes before we restore. - _tls.is_tracing = get_global_debugger() is not None - + if should_trace: + pydb.enable_tracing() else: - - def enable_or_disable(enable): - if enable: - log.info("Enabling tracing on thread {0}", tid) - pydb.enable_tracing() - else: - log.info("Disabling tracing on thread {0}", tid) - pydb.disable_tracing() - _tls.is_tracing = enable - - # Context managers don't do anything unless used in a with-statement - that is, - # even the code up to yield won't run. But we want callers to be able to omit - # with-statement for this function, if they don't want to restore. So, we apply - # the change directly out here in the non-generator context, so that it happens - # immediately - and then return a context manager that is solely for the purpose - # of restoring the original value, which the caller can use or discard. - - @contextlib.contextmanager - def restore_tracing(): - try: - yield - finally: - enable_or_disable(was_tracing) - - enable_or_disable(should_trace) - return restore_tracing() + pydb.disable_tracing() diff --git a/src/debugpy/server/attach_pid_injected.py b/src/debugpy/server/attach_pid_injected.py index 9fe1c6e8..9e64dabe 100644 --- a/src/debugpy/server/attach_pid_injected.py +++ b/src/debugpy/server/attach_pid_injected.py @@ -4,6 +4,8 @@ from __future__ import absolute_import, division, print_function, unicode_literals +"""Script injected into the debuggee process during attach-to-PID.""" + import os @@ -11,7 +13,8 @@ __file__ = os.path.abspath(__file__) _debugpy_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -def attach(host, port, client, log_dir=None, client_access_token=None): +def attach(setup): + log = None try: import sys @@ -53,40 +56,38 @@ def attach(host, port, client, log_dir=None, client_access_token=None): raise sys.path.insert(0, _debugpy_dir) - import debugpy - - # NOTE: Don't do sys.path.remove here it will remove all instances of that path - # and the user may have set that to debugpy path via PYTHONPATH - assert sys.path[0] == _debugpy_dir - del sys.path[0] - - from debugpy.common import log - from debugpy.server import options - - import pydevd + try: + import debugpy + import debugpy.server + from debugpy.common import log + import pydevd + finally: + assert sys.path[0] == _debugpy_dir + del sys.path[0] py_db = pydevd.get_global_debugger() if py_db is not None: py_db.dispose_and_kill_all_pydevd_threads(wait=False) - if log_dir is not None: - log.log_dir = log_dir - options.client = client - options.host = host - options.port = port - options.client_access_token = client_access_token + if setup["log_to"] is not None: + debugpy.log_to(setup["log_to"]) + log.info("Configuring injected debugpy: {0!j}", setup) - if options.client: - debugpy.attach((options.host, options.port)) + if setup["mode"] == "listen": + debugpy.listen(setup["address"]) + elif setup["mode"] == "connect": + debugpy.connect( + setup["address"], access_token=setup["adapter_access_token"] + ) else: - debugpy.enable_attach((options.host, options.port)) - - from debugpy.common import log - - log.info("Debugger successfully injected") + raise AssertionError(repr(setup)) except: import traceback traceback.print_exc() - raise log.exception() + if log is not None: + log.exception() + raise + + log.info("debugpy injected successfully") diff --git a/src/debugpy/server/cli.py b/src/debugpy/server/cli.py index 299bdb08..281d5fe8 100644 --- a/src/debugpy/server/cli.py +++ b/src/debugpy/server/cli.py @@ -4,7 +4,9 @@ from __future__ import absolute_import, division, print_function, unicode_literals +import json import os +import re import runpy import sys @@ -16,7 +18,7 @@ import pydevd import debugpy from debugpy.common import compat, fmt, log -from debugpy.server import options +from debugpy.server import api TARGET = " | -m | -c | --pid " @@ -24,16 +26,30 @@ TARGET = " | -m | -c | --pid " HELP = """debugpy {0} See https://aka.ms/debugpy for documentation. -Usage: debugpy [--client] --host
[--port ] - [--wait] - [--no-subprocesses] - [--log-dir ] [--log-stderr] +Usage: debugpy --listen [
:] + [--config- ]... + [--log-to ] [--log-to-stderr] {1} """.format( debugpy.__version__, TARGET ) +class Options(object): + mode = None + address = None + log_to = None + log_to_stderr = False + target = None + target_kind = None + wait_for_client = False + adapter_access_token = None + + +options = Options() +options.config = {"subProcess": True} + + def in_range(parser, start, stop): def parse(s): n = parser(s) @@ -46,8 +62,6 @@ def in_range(parser, start, stop): return parse -port = in_range(int, 0, 2 ** 16) - pid = in_range(int, 0, None) @@ -61,28 +75,66 @@ def print_version_and_exit(switch, it): sys.exit(0) -def set_arg(varname, parser=(lambda x: x), target=options): +def set_arg(varname, parser=(lambda x: x)): def do(arg, it): value = parser(next(it)) - setattr(target, varname, value) + setattr(options, varname, value) return do -def set_const(varname, value, target=options): +def set_const(varname, value): def do(arg, it): - setattr(target, varname, value) + setattr(options, varname, value) return do -def set_log_stderr(): +def set_address(mode): def do(arg, it): - log.stderr.levels |= set(log.LEVELS) + if options.address is not None: + raise ValueError("--listen and --connect are mutually exclusive") + + # It's either host:port, or just port. + value = next(it) + host, sep, port = value.partition(":") + if not sep: + host = "127.0.0.1" + port = value + try: + port = int(port) + except Exception: + port = -1 + if not (0 <= port < 2 ** 16): + raise ValueError("invalid port number") + + options.mode = mode + options.address = (host, port) return do +def set_config(arg, it): + prefix = "--configure-" + assert arg.startswith(prefix) + name = arg[len(prefix) :] + value = next(it) + + if name not in options.config: + raise ValueError(fmt("unknown property {0!r}", name)) + + expected_type = type(options.config[name]) + try: + if expected_type is bool: + value = {"true": True, "false": False}[value.lower()] + else: + value = expected_type(value) + except Exception: + raise ValueError(fmt("{0!r} must be a {1}", name, expected_type.__name__)) + + options.config[name] = value + + def set_target(kind, parser=(lambda x: x), positional=False): def do(arg, it): options.target_kind = kind @@ -93,34 +145,33 @@ def set_target(kind, parser=(lambda x: x), positional=False): # fmt: off switches = [ - # Switch Placeholder Action Required - # ====== =========== ====== ======== + # Switch Placeholder Action + # ====== =========== ====== # Switches that are documented for use by end users. - (("-?", "-h", "--help"), None, print_help_and_exit, False), - (("-V", "--version"), None, print_version_and_exit, False), - ("--client", None, set_const("client", True), False), - ("--host", "
", set_arg("host"), True), - ("--port", "", set_arg("port", port), False), - ("--wait", None, set_const("wait", True), False), - ("--no-subprocesses", None, set_const("multiprocess", False), False), - ("--log-dir", "", set_arg("log_dir", target=log), False), - ("--log-stderr", None, set_log_stderr(), False), + ("-(\?|h|-help)", None, print_help_and_exit), + ("-(V|-version)", None, print_version_and_exit), + ("--log-to" , "", set_arg("log_to")), + ("--log-to-stderr", None, set_const("log_to_stderr", True)), + ("--listen", "
", set_address("listen")), + ("--wait-for-client", None, set_const("wait_for_client", True)), + ("--configure-.+", "", set_config), # Switches that are used internally by the IDE or debugpy itself. - ("--client-access-token", "", set_arg("client_access_token"), False), + ("--connect", "
", set_address("connect")), + ("--adapter-access-token", "", set_arg("adapter_access_token")), # Targets. The "" entry corresponds to positional command line arguments, # i.e. the ones not preceded by any switch name. - ("", "", set_target("file", positional=True), False), - ("-m", "", set_target("module"), False), - ("-c", "", set_target("code"), False), - ("--pid", "", set_target("pid", pid), False), + ("", "", set_target("file", positional=True)), + ("-m", "", set_target("module")), + ("-c", "", set_target("code")), + ("--pid", "", set_target("pid", pid)), ] # fmt: on -def parse(args, options=options): +def parse(args): seen = set() it = (compat.filename(arg) for arg in args) @@ -131,18 +182,16 @@ def parse(args, options=options): raise ValueError("missing target: " + TARGET) switch = arg if arg.startswith("-") else "" - for i, (sw, placeholder, action, _) in enumerate(switches): - if not isinstance(sw, tuple): - sw = (sw,) - if switch in sw: + for pattern, placeholder, action in switches: + if re.match("^(" + pattern + ")$", switch): break else: raise ValueError("unrecognized switch " + switch) - if i in seen: + if switch in seen: raise ValueError("duplicate switch " + switch) else: - seen.add(i) + seen.add(switch) try: action(arg, it) @@ -155,38 +204,42 @@ def parse(args, options=options): if options.target is not None: break - for i, (sw, placeholder, _, required) in enumerate(switches): - if not required or i in seen: - continue - if isinstance(sw, tuple): - sw = sw[0] - message = fmt("missing required {0}", sw) - if placeholder is not None: - message += " " + placeholder - raise ValueError(message) + if options.mode is None: + raise ValueError("either --listen or --connect is required") + if options.adapter_access_token is not None and options.mode != "connect": + raise ValueError("--adapter-access-token requires --connect") + if options.target_kind == "pid" and options.wait_for_client: + raise ValueError("--pid does not support --wait-for-client") - if options.target_kind == "pid" and options.wait: - raise ValueError("--pid does not support --wait") + assert options.target is not None + assert options.target_kind is not None + assert options.address is not None return it -def setup_debug_server(argv_0): - # We need to set up sys.argv[0] before invoking attach() or enable_attach(), +def start_debugging(argv_0): + # We need to set up sys.argv[0] before invoking either listen() or connect(), # because they use it to report the "process" event. Thus, we can't rely on # run_path() and run_module() doing that, even though they will eventually. sys.argv[0] = compat.filename(argv_0) log.debug("sys.argv after patching: {0!r}", sys.argv) - debug = debugpy.attach if options.client else debugpy.enable_attach - debug(address=options, multiprocess=options) + debugpy.configure(options.config) - if options.wait: - debugpy.wait_for_attach() + if options.mode == "listen": + debugpy.listen(options.address) + elif options.mode == "connect": + debugpy.connect(options.address, access_token=options.adapter_access_token) + else: + raise AssertionError(repr(options.mode)) + + if options.wait_for_client: + debugpy.wait_for_client() def run_file(): - setup_debug_server(options.target) + start_debugging(options.target) # run_path has one difference with invoking Python from command-line: # if the target is a file (rather than a directory), it does not add its @@ -200,6 +253,7 @@ def run_file(): log.describe_environment("Pre-launch environment:") log.info("Running file {0!j}", options.target) + runpy.run_path(options.target, run_name="__main__") @@ -225,7 +279,7 @@ def run_module(): except Exception: log.exception("Error determining module path for sys.argv") - setup_debug_server(argv_0) + start_debugging(argv_0) # On Python 2, module name must be a non-Unicode string, because it ends up # a part of module's __package__, and Python will refuse to run the module @@ -254,58 +308,58 @@ def run_module(): def run_code(): - log.describe_environment("Pre-launch environment:") - log.info("Running code:\n\n{0}", options.target) - # Add current directory to path, like Python itself does for -c. sys.path.insert(0, "") code = compile(options.target, "", "exec") - setup_debug_server("-c") + start_debugging("-c") + + log.describe_environment("Pre-launch environment:") + log.info("Running code:\n\n{0}", options.target) + eval(code, {}) def attach_to_pid(): - log.info("Attaching to process with PID={0}", options.target) - pid = options.target + log.info("Attaching to process with PID={0}", pid) - attach_pid_injected_dirname = os.path.join( - os.path.dirname(debugpy.__file__), "server" - ) - assert os.path.exists(attach_pid_injected_dirname) - - log_dir = (log.log_dir or "").replace("\\", "/") encode = lambda s: list(bytearray(s.encode("utf-8"))) if s is not None else None + + script_dir = os.path.dirname(debugpy.server.__file__) + assert os.path.exists(script_dir) + script_dir = encode(script_dir) + setup = { - "script": encode(attach_pid_injected_dirname), - "host": encode(options.host), - "port": options.port, - "client": options.client, - "log_dir": encode(log_dir), - "client_access_token": encode(options.client_access_token), + "mode": options.mode, + "address": options.address, + "wait_for_client": options.wait_for_client, + "log_to": options.log_to, + "adapter_access_token": options.adapter_access_token, } + setup = encode(json.dumps(setup)) python_code = """ -import sys; import codecs; +import json; +import sys; + decode = lambda s: codecs.utf_8_decode(bytearray(s))[0] if s is not None else None; -script_path = decode({script}); -sys.path.insert(0, script_path); + +script_dir = decode({script_dir}); +setup = json.loads(decode({setup})); + +sys.path.insert(0, script_dir); import attach_pid_injected; -sys.path.remove(script_path); -host = decode({host}); -log_dir = decode({log_dir}) or None; -client_access_token = decode({client_access_token}) or None; -attach_pid_injected.attach( - port={port}, - host=host, - client={client}, - log_dir=log_dir, - client_access_token=client_access_token, -) +del sys.path[0]; + +attach_pid_injected.attach(setup); """ - python_code = python_code.replace("\r", "").replace("\n", "").format(**setup) + python_code = ( + python_code.replace("\r", "") + .replace("\n", "") + .format(script_dir=script_dir, setup=setup) + ) log.info("Code to be injected: \n{0}", python_code.replace(";", ";\n")) # pydevd restriction on characters in injected code. @@ -343,8 +397,13 @@ def main(): print(HELP + "\nError: " + str(ex), file=sys.stderr) sys.exit(2) - log.to_file(prefix="debugpy.server") - log.describe_environment("debugpy.server startup environment:") + if options.log_to is not None: + debugpy.log_to(options.log_to) + if options.log_to_stderr: + debugpy.log_to(sys.stderr) + + api.ensure_logging() + log.info( "sys.argv before parsing: {0!r}\n" " after parsing: {1!r}", original_argv, diff --git a/tests/debug/runners.py b/tests/debug/runners.py index 71b62dc6..3b00a3fc 100644 --- a/tests/debug/runners.py +++ b/tests/debug/runners.py @@ -222,30 +222,36 @@ def attach_by_socket( port = config["port"] = attach_by_socket.port if method == "cli": - args = [os.path.dirname(debugpy.__file__)] + args = [ + os.path.dirname(debugpy.__file__), + "--listen", + compat.filename_str(host) + ":" + str(port), + ] if wait: - args += ["--wait"] - args += ["--host", compat.filename_str(host), "--port", str(port)] + args += ["--wait-for-client"] if log_dir is not None: - args += ["--log-dir", log_dir] + args += ["--log-to", log_dir] debuggee_setup = None elif method == "api": args = [] debuggee_setup = """ import debugpy -debugpy.enable_attach(({host!r}, {port!r}), {args}) +if {log_dir!r}: + debugpy.log_to({log_dir!r}) +debugpy.listen(({host!r}, {port!r})) if {wait!r}: - debugpy.wait_for_attach() + debugpy.wait_for_client() """ - attach_args = "" if log_dir is None else fmt("log_dir={0!r}", log_dir) - debuggee_setup = fmt(debuggee_setup, host=host, port=port, wait=wait, args=attach_args) + debuggee_setup = fmt( + debuggee_setup, host=host, port=port, wait=wait, log_dir=log_dir + ) else: raise ValueError args += target.cli(session.spawn_debuggee.env) session.spawn_debuggee(args, cwd=cwd, setup=debuggee_setup) if wait: - session.wait_for_enable_attach() + session.wait_for_adapter_socket() session.connect_to_adapter((host, port)) return session.request_attach() diff --git a/tests/debug/session.py b/tests/debug/session.py index c70d3e32..7ce92935 100644 --- a/tests/debug/session.py +++ b/tests/debug/session.py @@ -383,7 +383,7 @@ class Session(object): for fd in popen_fds.values(): os.close(fd) - def wait_for_enable_attach(self): + def wait_for_adapter_socket(self): log.info("Waiting for {0} to open the IDE listener socket...", self.adapter_id) while not self.adapter_endpoints.check(): time.sleep(0.1) diff --git a/tests/debugpy/server/test_cli.py b/tests/debugpy/server/test_cli.py index 893d0404..9a48a136 100644 --- a/tests/debugpy/server/test_cli.py +++ b/tests/debugpy/server/test_cli.py @@ -10,6 +10,7 @@ import subprocess import sys from debugpy.common import log +from tests.patterns import some @pytest.fixture @@ -19,7 +20,7 @@ def cli(pyfile): import os import pickle import sys - from debugpy.server import cli, options + from debugpy.server import cli try: sys.argv[1:] = cli.parse(sys.argv[1:]) @@ -29,15 +30,16 @@ def cli(pyfile): else: # We only care about options that correspond to public switches. options = { - name: getattr(options, name) + name: getattr(cli.options, name) for name in [ - "target_kind", + "address", + "config", + "log_to", + "log_to_stderr", + "mode", "target", - "host", - "port", - "client", - "wait", - "multiprocess", + "target_kind", + "wait_for_client", ] } os.write(1, pickle.dumps([sys.argv[1:], options])) @@ -60,25 +62,27 @@ def cli(pyfile): @pytest.mark.parametrize("target_kind", ["file", "module", "code"]) -@pytest.mark.parametrize("port", ["", "8888"]) -@pytest.mark.parametrize("client", ["", "client"]) -@pytest.mark.parametrize("wait", ["", "wait"]) -@pytest.mark.parametrize("subprocesses", ["", "subprocesses"]) -@pytest.mark.parametrize("extra", ["", "extra"]) -def test_targets(cli, target_kind, port, client, wait, subprocesses, extra): - args = ["--host", "localhost"] +@pytest.mark.parametrize("mode", ["listen", "connect"]) +@pytest.mark.parametrize("address", ["8888", "localhost:8888"]) +@pytest.mark.parametrize("wait_for_client", ["", "wait_for_client"]) +@pytest.mark.parametrize("script_args", ["", "script_args"]) +def test_targets(cli, target_kind, mode, address, wait_for_client, script_args): + expected_options = { + "mode": mode, + "target_kind": target_kind, + "wait_for_client": bool(wait_for_client), + } - if port: - args += ["--port", port] + args = ["--" + mode, address] - if client: - args += ["--client"] + host, sep, port = address.partition(":") + if sep: + expected_options["address"] = (host, int(port)) + else: + expected_options["address"] = ("127.0.0.1", int(address)) - if wait: - args += ["--wait"] - - if not subprocesses: - args += ["--no-subprocesses"] + if wait_for_client: + args += ["--wait-for-client"] if target_kind == "file": target = "spam.py" @@ -91,49 +95,55 @@ def test_targets(cli, target_kind, port, client, wait, subprocesses, extra): args += ["-c", target] else: pytest.fail(target_kind) + expected_options["target"] = target - if extra: - extra = [ + if script_args: + script_args = [ "ham", - "--client", - "--wait", + "--listen", + "--wait-for-client", "-y", "spam", "--", - "--host", - "--port", + "--connect", "-c", "--something", "-m", ] - args += extra + args += script_args else: - extra = [] + script_args = [] argv, options = cli(args) - - assert argv == extra - assert options == { - "target_kind": target_kind, - "target": target, - "host": "localhost", - "port": int(port) if port else 5678, - "wait": bool(wait), - "multiprocess": bool(subprocesses), - "client": bool(client), - } + assert argv == script_args + assert options == some.dict.containing(expected_options) -def test_unsupported_arg(cli): +@pytest.mark.parametrize("value", ["", True, False]) +def test_configure_subProcess(cli, value): + args = ["--listen", "8888"] + + if value == "": + value = True + else: + args += ["--configure-subProcess", str(value)] + + args += ["spam.py"] + _, options = cli(args) + + assert options["config"]["subProcess"] == value + + +def test_unsupported_switch(cli): with pytest.raises(Exception): - cli(["--port", "8888", "--xyz", "123", "spam.py"]) + cli(["--listen", "8888", "--xyz", "123", "spam.py"]) -def test_host_required(cli): +def test_unsupported_configure(cli): with pytest.raises(Exception): - cli(["--port", "8888", "-m", "spam"]) + cli(["--connect", "127.0.0.1:8888", "--configure-xyz", "123", "spam.py"]) -def test_host_empty(cli): - _, options = cli(["--host", "", "--port", "8888", "spam.py"]) - assert options["host"] == "" +def test_address_required(cli): + with pytest.raises(Exception): + cli(["-m", "spam"]) diff --git a/tests/debugpy/test_attach.py b/tests/debugpy/test_attach.py index 166fb2fb..1bb63b13 100644 --- a/tests/debugpy/test_attach.py +++ b/tests/debugpy/test_attach.py @@ -11,11 +11,11 @@ from tests.debug import runners, targets from tests.patterns import some -@pytest.mark.parametrize("stop_method", ["break_into_debugger", "pause"]) -@pytest.mark.parametrize("is_attached", ["is_attached", ""]) -@pytest.mark.parametrize("wait_for_attach", ["wait_for_attach", ""]) +@pytest.mark.parametrize("stop_method", ["breakpoint", "pause"]) +@pytest.mark.parametrize("is_client_connected", ["is_client_connected", ""]) +@pytest.mark.parametrize("wait_for_client", ["wait_for_client", ""]) @pytest.mark.parametrize("target", targets.all) -def test_attach_api(pyfile, target, wait_for_attach, is_attached, stop_method): +def test_attach_api(pyfile, target, wait_for_client, is_client_connected, stop_method): @pyfile def code_to_debug(): import debuggee @@ -25,25 +25,25 @@ def test_attach_api(pyfile, target, wait_for_attach, is_attached, stop_method): from debuggee import backchannel, scratchpad debuggee.setup() - _, host, port, wait_for_attach, is_attached, stop_method = sys.argv + _, host, port, wait_for_client, is_client_connected, stop_method = sys.argv port = int(port) - debugpy.enable_attach((host, port)) + debugpy.listen(address=(host, port)) - if wait_for_attach: - backchannel.send("wait_for_attach") - debugpy.wait_for_attach() + if wait_for_client: + backchannel.send("wait_for_client") + debugpy.wait_for_client() - if is_attached: - backchannel.send("is_attached") - while not debugpy.is_attached(): - print("looping until is_attached") + if is_client_connected: + backchannel.send("is_client_connected") + while not debugpy.is_client_connected(): + print("looping until is_client_connected()") time.sleep(0.1) - if stop_method == "break_into_debugger": - backchannel.send("break_into_debugger?") + if stop_method == "breakpoint": + backchannel.send("breakpoint?") assert backchannel.receive() == "proceed" - debugpy.break_into_debugger() - print("break") # @break_into_debugger + debugpy.breakpoint() + print("break") # @breakpoint else: scratchpad["paused"] = False backchannel.send("loop?") @@ -58,25 +58,25 @@ def test_attach_api(pyfile, target, wait_for_attach, is_attached, stop_method): backchannel = session.open_backchannel() session.spawn_debuggee( - [code_to_debug, host, port, wait_for_attach, is_attached, stop_method] + [code_to_debug, host, port, wait_for_client, is_client_connected, stop_method] ) - session.wait_for_enable_attach() + session.wait_for_adapter_socket() session.connect_to_adapter((host, port)) with session.request_attach(): pass - if wait_for_attach: - assert backchannel.receive() == "wait_for_attach" + if wait_for_client: + assert backchannel.receive() == "wait_for_client" - if is_attached: - assert backchannel.receive() == "is_attached" + if is_client_connected: + assert backchannel.receive() == "is_client_connected" - if stop_method == "break_into_debugger": - assert backchannel.receive() == "break_into_debugger?" + if stop_method == "breakpoint": + assert backchannel.receive() == "breakpoint?" backchannel.send("proceed") session.wait_for_stop( - expected_frames=[some.dap.frame(code_to_debug, "break_into_debugger")] + expected_frames=[some.dap.frame(code_to_debug, "breakpoint")] ) elif stop_method == "pause": assert backchannel.receive() == "loop?" @@ -100,13 +100,13 @@ def test_reattach(pyfile, target, run): from debuggee import scratchpad debuggee.setup() - debugpy.break_into_debugger() + debugpy.breakpoint() object() # @first scratchpad["exit"] = False while not scratchpad["exit"]: time.sleep(0.1) - debugpy.break_into_debugger() + debugpy.breakpoint() object() # @second with debug.Session() as session1: diff --git a/tests/debugpy/test_breakpoints.py b/tests/debugpy/test_breakpoints.py index f957a7e4..bffa8af4 100644 --- a/tests/debugpy/test_breakpoints.py +++ b/tests/debugpy/test_breakpoints.py @@ -422,7 +422,7 @@ def test_deep_stacks(pyfile, target, run): @pytest.mark.parametrize("target", targets.all) -@pytest.mark.parametrize("func", ["breakpoint", "debugpy.break_into_debugger"]) +@pytest.mark.parametrize("func", ["breakpoint", "debugpy.breakpoint"]) def test_break_api(pyfile, target, run, func): if func == "breakpoint" and sys.version_info < (3, 7): pytest.skip("breakpoint() was introduced in Python 3.7") diff --git a/tests/debugpy/test_evaluate.py b/tests/debugpy/test_evaluate.py index 95211f72..09fe0892 100644 --- a/tests/debugpy/test_evaluate.py +++ b/tests/debugpy/test_evaluate.py @@ -265,7 +265,7 @@ def test_unicode(pyfile, target, run): # this needs to do a roundabout way of setting it to avoid parse issues. globals()["\u16A0"] = 123 debuggee.setup() - debugpy.break_into_debugger() + debugpy.breakpoint() print("break") with debug.Session() as session: @@ -520,7 +520,7 @@ def test_set_variable(pyfile, target, run): debuggee.setup() a = 1 - debugpy.break_into_debugger() + debugpy.breakpoint() backchannel.send(a) with debug.Session() as session: diff --git a/tests/debugpy/test_log.py b/tests/debugpy/test_log.py index d0c8e110..ba5387e2 100644 --- a/tests/debugpy/test_log.py +++ b/tests/debugpy/test_log.py @@ -40,7 +40,7 @@ def test_log_dir(pyfile, tmpdir, target, method): debuggee.setup() # Depending on the method, attach_by_socket will use either `debugpy --log-dir ...` - # or `enable_attach(log_dir=) ...`. + # or `debugpy.log_to() ...`. run = runners.attach_by_socket[method].with_options(log_dir=tmpdir.strpath) with check_logs(tmpdir, run): with debug.Session() as session: diff --git a/tests/debugpy/test_run.py b/tests/debugpy/test_run.py index 9509ec86..d2de6ea3 100644 --- a/tests/debugpy/test_run.py +++ b/tests/debugpy/test_run.py @@ -113,7 +113,7 @@ def test_wait_on_exit( import debugpy debuggee.setup() - debugpy.break_into_debugger() + debugpy.breakpoint() print() # line on which it'll actually break sys.exit(int(sys.argv[1])) diff --git a/tests/debugpy/test_system_info.py b/tests/debugpy/test_system_info.py index b9898bc3..138f0924 100644 --- a/tests/debugpy/test_system_info.py +++ b/tests/debugpy/test_system_info.py @@ -62,7 +62,7 @@ def test_debugpySystemInfo(pyfile, target, run, expected_system_info): import debugpy debuggee.setup() - debugpy.break_into_debugger() + debugpy.breakpoint() print() with debug.Session() as session: diff --git a/tests/debugpy/test_tracing.py b/tests/debugpy/test_tracing.py index acf1febf..ea28bae6 100644 --- a/tests/debugpy/test_tracing.py +++ b/tests/debugpy/test_tracing.py @@ -17,9 +17,6 @@ def test_tracing(pyfile, target, run): debuggee.setup() def func(expected_tracing): - assert debugpy.tracing() == expected_tracing, "inside func({0!r})".format( - expected_tracing - ) print(1) # @inner1 # Test nested change/restore. Going from False to True only works entirely @@ -31,23 +28,17 @@ def test_tracing(pyfile, target, run): def inner2(): print(2) # @inner2 - with debugpy.tracing(not expected_tracing): - assert debugpy.tracing() != expected_tracing, "inside with-statement" - inner2() - assert debugpy.tracing() == expected_tracing, "after with-statement" + debugpy.trace_this_thread(not expected_tracing) + inner2() + debugpy.trace_this_thread(expected_tracing) print(3) # @inner3 - assert debugpy.tracing(), "before tracing(False)" - debugpy.tracing(False) - assert not debugpy.tracing(), "after tracing(False)" - + debugpy.trace_this_thread(False) print(0) # @outer1 func(False) - debugpy.tracing(True) - assert debugpy.tracing(), "after tracing(True)" - + debugpy.trace_this_thread(True) print(0) # @outer2 func(True)