From 8575121507519c9f2a20e3b63d3cc77e514ed538 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Tue, 15 Jul 2025 11:47:29 +1000 Subject: [PATCH 1/2] Expose CLI Options through public API Expose a public API that can retrieve the processed CLI options for the current process launched through the debugpy CLI. This enables code to be able to retrieve options like the port and adapter access token to be used for launching their own child process' that can be debugged. --- src/debugpy/__init__.py | 1 + src/debugpy/public_api.py | 49 +++++++++++++++++++++++++++++++++ src/debugpy/server/cli.py | 10 ++++--- tests/debugpy/test_cli_args.py | 34 +++++++++++++++++++++++ tests/debugpy/test_multiproc.py | 9 ++++-- 5 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 tests/debugpy/test_cli_args.py diff --git a/src/debugpy/__init__.py b/src/debugpy/__init__.py index 975bec79b..890d10afc 100644 --- a/src/debugpy/__init__.py +++ b/src/debugpy/__init__.py @@ -15,6 +15,7 @@ "configure", "connect", "debug_this_thread", + "get_cli_options", "is_client_connected", "listen", "log_to", diff --git a/src/debugpy/public_api.py b/src/debugpy/public_api.py index e490ea16f..298a53f6b 100644 --- a/src/debugpy/public_api.py +++ b/src/debugpy/public_api.py @@ -4,6 +4,7 @@ from __future__ import annotations +import dataclasses import functools import typing @@ -21,6 +22,21 @@ Endpoint = typing.Tuple[str, int] +@dataclasses.dataclass(frozen=True) +class CliOptions: + """Options that were passed to the debugpy CLI entry point.""" + mode: typing.Literal["connect", "listen"] + target_kind: typing.Literal["file", "module", "code", "pid"] + address: Endpoint + log_to: str | None = None + log_to_stderr: bool = False + target: str | None = None + wait_for_client: bool = False + adapter_access_token: str | None = None + config: dict[str, object] = dataclasses.field(default_factory=dict) + parent_session_pid: int | None = None + + def _api(cancelable=False): def apply(f): @functools.wraps(f) @@ -196,4 +212,37 @@ def trace_this_thread(__should_trace: bool): """ +def get_cli_options() -> CliOptions | None: + """Returns the CLI options that were processed by debugpy. + + These options are all the options after the CLI args and + environment variables that were processed on startup. + + If the debugpy CLI entry point was not called in this process, the + returned value is None. + """ + from debugpy.server import cli + + options = cli.options + if options.mode is None or options.target_kind is None or options.address is None: + # The CLI entrypoint was not called so there are no options present. + return None + + # We don't return the actual options object because we don't want callers + # to be able to mutate it. Instead we use a frozen dataclass as a snapshot + # with richer type annotations. + return CliOptions( + mode=options.mode, + target_kind=options.target_kind, + address=options.address, + log_to=options.log_to, + log_to_stderr=options.log_to_stderr, + target=options.target, + wait_for_client=options.wait_for_client, + adapter_access_token=options.adapter_access_token, + config=options.config, + parent_session_pid=options.parent_session_pid, + ) + + __version__: str = _version.get_versions()["version"] diff --git a/src/debugpy/server/cli.py b/src/debugpy/server/cli.py index 9f5693117..f289a1ce7 100644 --- a/src/debugpy/server/cli.py +++ b/src/debugpy/server/cli.py @@ -7,7 +7,7 @@ import re import sys from importlib.util import find_spec -from typing import Any, Union, Tuple, Dict +from typing import Any, Union, Tuple, Dict, Literal # debugpy.__main__ should have preloaded pydevd properly before importing this module. # Otherwise, some stdlib modules above might have had imported threading before pydevd @@ -23,6 +23,7 @@ from debugpy.common import log, sockets from debugpy.server import api +TargetKind = Literal["file", "module", "code", "pid"] TARGET = " | -m | -c | --pid " @@ -42,13 +43,14 @@ ) +# Changes here should be aligned with the public API CliOptions. class Options(object): - mode = None + mode: Union[Literal["connect", "listen"], None] = None address: Union[Tuple[str, int], None] = None log_to = None log_to_stderr = False target: Union[str, None] = None - target_kind: Union[str, None] = None + target_kind: Union[TargetKind, None] = None wait_for_client = False adapter_access_token = None config: Dict[str, Any] = {} @@ -145,7 +147,7 @@ def set_config(arg, it): options.config[name] = value -def set_target(kind: str, parser=(lambda x: x), positional=False): +def set_target(kind: TargetKind, parser=(lambda x: x), positional=False): def do(arg, it): options.target_kind = kind target = parser(arg if positional else next(it)) diff --git a/tests/debugpy/test_cli_args.py b/tests/debugpy/test_cli_args.py new file mode 100644 index 000000000..98f4da13f --- /dev/null +++ b/tests/debugpy/test_cli_args.py @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from tests import debug + + +def test_cli_options_with_no_debugger(): + import debugpy + + cli_options = debugpy.get_cli_options() + assert cli_options is None + + +def test_cli_options_under_file_connect(pyfile, target, run): + @pyfile + def code_to_debug(): + import debugpy + + import debuggee + from debuggee import backchannel + + debuggee.setup() + backchannel.send(debugpy.get_cli_options()) + + with debug.Session() as session: + backchannel = session.open_backchannel() + + with run(session, target(code_to_debug)): + pass + + cli_options = backchannel.receive() + assert cli_options['mode'] == 'connect' + assert cli_options['target_kind'] == 'file' diff --git a/tests/debugpy/test_multiproc.py b/tests/debugpy/test_multiproc.py index 8b9171d82..d89df053c 100644 --- a/tests/debugpy/test_multiproc.py +++ b/tests/debugpy/test_multiproc.py @@ -630,7 +630,7 @@ def parent(): import subprocess import sys - from debugpy.server import cli as debugpy_cli + import debugpy debuggee.setup() @@ -642,8 +642,11 @@ def parent(): else: argv = ["/bin/sh", "-c"] - host, port = debugpy_cli.options.address - access_token = debugpy_cli.options.adapter_access_token + cli_opts = debugpy.get_cli_options() + assert cli_opts, "No CLI options found" + + host, port = cli_opts.address + access_token = cli_opts.adapter_access_token shell_args = [ sys.executable, From 98d5f6bae48a1aebc8562f9ee3cc0135fa3e80c3 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Wed, 16 Jul 2025 08:08:37 +1000 Subject: [PATCH 2/2] Fix test by sending dict not dataclass object --- tests/debugpy/test_cli_args.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/debugpy/test_cli_args.py b/tests/debugpy/test_cli_args.py index 98f4da13f..e7a55d5ec 100644 --- a/tests/debugpy/test_cli_args.py +++ b/tests/debugpy/test_cli_args.py @@ -15,13 +15,14 @@ def test_cli_options_with_no_debugger(): def test_cli_options_under_file_connect(pyfile, target, run): @pyfile def code_to_debug(): + import dataclasses import debugpy import debuggee from debuggee import backchannel debuggee.setup() - backchannel.send(debugpy.get_cli_options()) + backchannel.send(dataclasses.asdict(debugpy.get_cli_options())) with debug.Session() as session: backchannel = session.open_backchannel()