diff --git a/src/debugpy/__init__.py b/src/debugpy/__init__.py index 975bec79..890d10af 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 e490ea16..298a53f6 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 9f569311..f289a1ce 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 00000000..e7a55d5e --- /dev/null +++ b/tests/debugpy/test_cli_args.py @@ -0,0 +1,35 @@ +# 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 dataclasses + import debugpy + + import debuggee + from debuggee import backchannel + + debuggee.setup() + backchannel.send(dataclasses.asdict(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 8b9171d8..d89df053 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,