1 # Copyright (c) Microsoft Corporation. All rights reserved. 2 # Licensed under the MIT License. See LICENSE in the project root 3 # for license information. 4 5 import json 6 import os 7 import re 8 import sys 9 from importlib.util import find_spec 10 from typing import Any, Union, Tuple, Dict 11 12 # debugpy.__main__ should have preloaded pydevd properly before importing this module. 13 # Otherwise, some stdlib modules above might have had imported threading before pydevd 14 # could perform the necessary detours in it. 15 assert "pydevd" in sys.modules 16 import pydevd 17 18 # Note: use the one bundled from pydevd so that it's invisible for the user. 19 from _pydevd_bundle import pydevd_runpy as runpy 20 21 import debugpy 22 import debugpy.server 23 from debugpy.common import log 24 from debugpy.server import api 25 26 27 TARGET = "<filename> | -m <module> | -c <code> | --pid <pid>" 28 29 HELP = """debugpy {0} 30 See https://aka.ms/debugpy for documentation. 31 32 Usage: debugpy --listen | --connect 33 [<host>:]<port> 34 [--wait-for-client] 35 [--configure-<name> <value>]... 36 [--log-to <path>] [--log-to-stderr] 37 {1} 38 [<arg>]... 39 """.format( 40 debugpy.__version__, TARGET 41 ) 42 43 44 class Options(object): 45 mode = None 46 address: Union[Tuple[str, int], None] = None 47 log_to = None 48 log_to_stderr = False 49 target: Union[str, None] = None 50 target_kind: Union[str, None] = None 51 wait_for_client = False 52 adapter_access_token = None 53 config: Dict[str, Any] = {} 54 55 56 options = Options() 57 options.config = {"qt": "none", "subProcess": True} 58 59 60 def in_range(parser, start, stop): 61 def parse(s): 62 n = parser(s) 63 if start is not None and n < start: 64 raise ValueError("must be >= {0}".format(start)) 65 if stop is not None and n >= stop: 66 raise ValueError("must be < {0}".format(stop)) 67 return n 68 69 return parse 70 71 72 pid = in_range(int, 0, None) 73 74 75 def print_help_and_exit(switch, it): 76 print(HELP, file=sys.stderr) 77 sys.exit(0) 78 79 80 def print_version_and_exit(switch, it): 81 print(debugpy.__version__) 82 sys.exit(0) 83 84 85 def set_arg(varname, parser=(lambda x: x)): 86 def do(arg, it): 87 value = parser(next(it)) 88 setattr(options, varname, value) 89 90 return do 91 92 93 def set_const(varname, value): 94 def do(arg, it): 95 setattr(options, varname, value) 96 97 return do 98 99 100 def set_address(mode): 101 def do(arg, it): 102 if options.address is not None: 103 raise ValueError("--listen and --connect are mutually exclusive") 104 105 # It's either host:port, or just port. 106 value = next(it) 107 host, sep, port = value.partition(":") 108 if not sep: 109 host = "127.0.0.1" 110 port = value 111 try: 112 port = int(port) 113 except Exception: 114 port = -1 115 if not (0 <= port < 2**16): 116 raise ValueError("invalid port number") 117 118 options.mode = mode 119 options.address = (host, port) 120 121 return do 122 123 124 def set_config(arg, it): 125 prefix = "--configure-" 126 assert arg.startswith(prefix) 127 name = arg[len(prefix) :] 128 value = next(it) 129 130 if name not in options.config: 131 raise ValueError("unknown property {0!r}".format(name)) 132 133 expected_type = type(options.config[name]) 134 try: 135 if expected_type is bool: 136 value = {"true": True, "false": False}[value.lower()] 137 else: 138 value = expected_type(value) 139 except Exception: 140 raise ValueError("{0!r} must be a {1}".format(name, expected_type.__name__)) 141 142 options.config[name] = value 143 144 145 def set_target(kind: str, parser=(lambda x: x), positional=False): 146 def do(arg, it): 147 options.target_kind = kind 148 target = parser(arg if positional else next(it)) 149 150 if isinstance(target, bytes): 151 # target may be the code, so, try some additional encodings... 152 try: 153 target = target.decode(sys.getfilesystemencoding()) 154 except UnicodeDecodeError: 155 try: 156 target = target.decode("utf-8") 157 except UnicodeDecodeError: 158 import locale 159 160 target = target.decode(locale.getpreferredencoding(False)) 161 162 options.target = target 163 164 return do 165 166 167 # fmt: off 168 switches = [ 169 # Switch Placeholder Action 170 # ====== =========== ====== 171 172 # Switches that are documented for use by end users. 173 ("-(\\?|h|-help)", None, print_help_and_exit), 174 ("-(V|-version)", None, print_version_and_exit), 175 ("--log-to" , "<path>", set_arg("log_to")), 176 ("--log-to-stderr", None, set_const("log_to_stderr", True)), 177 ("--listen", "<address>", set_address("listen")), 178 ("--connect", "<address>", set_address("connect")), 179 ("--wait-for-client", None, set_const("wait_for_client", True)), 180 ("--configure-.+", "<value>", set_config), 181 182 # Switches that are used internally by the client or debugpy itself. 183 ("--adapter-access-token", "<token>", set_arg("adapter_access_token")), 184 185 # Targets. The "" entry corresponds to positional command line arguments, 186 # i.e. the ones not preceded by any switch name. 187 ("", "<filename>", set_target("file", positional=True)), 188 ("-m", "<module>", set_target("module")), 189 ("-c", "<code>", set_target("code")), 190 ("--pid", "<pid>", set_target("pid", pid)), 191 ] 192 # fmt: on 193 194 195 # Consume all the args from argv 196 def consume_argv(): 197 while len(sys.argv) >= 2: 198 value = sys.argv[1] 199 del sys.argv[1] 200 yield value 201 202 203 # Consume all the args from a given list 204 def consume_args(args: list): 205 if args is sys.argv: 206 yield from consume_argv() 207 else: 208 while args: 209 value = args[0] 210 del args[0] 211 yield value 212 213 214 # Parse the args from the command line, then from the environment. 215 # Args from the environment are only used if they are not already set from the command line. 216 def parse_args(): 217 218 # keep track of the switches we've seen so far 219 seen = set() 220 221 parse_args_from_command_line(seen) 222 parse_args_from_environment(seen) 223 224 # if the target is not set, or is empty, this is an error 225 if options.target is None or options.target == "": 226 raise ValueError("missing target: " + TARGET) 227 228 if options.mode is None: 229 raise ValueError("either --listen or --connect is required") 230 if options.adapter_access_token is not None and options.mode != "connect": 231 raise ValueError("--adapter-access-token requires --connect") 232 if options.target_kind == "pid" and options.wait_for_client: 233 raise ValueError("--pid does not support --wait-for-client") 234 235 assert options.target_kind is not None 236 assert options.address is not None 237 238 239 def parse_args_from_command_line(seen: set): 240 parse_args_helper(sys.argv, seen) 241 242 243 def parse_args_from_environment(seenFromCommandLine: set): 244 args = os.environ.get("DEBUGPY_EXTRA_ARGV") 245 if not args: 246 return 247 248 argsList = args.split() 249 250 seenFromEnvironment = set() 251 parse_args_helper(argsList, seenFromCommandLine, seenFromEnvironment, True) 252 253 254 def parse_args_helper( 255 args: list, 256 seenFromCommandLine: set, 257 seenFromEnvironment: set = set(), 258 isFromEnvironment=False, 259 ): 260 iterator = consume_args(args) 261 262 while True: 263 try: 264 arg = next(iterator) 265 except StopIteration: 266 break 267 268 switch = arg 269 if not switch.startswith("-"): 270 switch = "" 271 for pattern, placeholder, action in switches: 272 if re.match("^(" + pattern + ")$", switch): 273 break 274 else: 275 raise ValueError("unrecognized switch " + switch) 276 277 # if we're parsing from the command line, and we've already seen the switch on the command line, this is an error 278 if not isFromEnvironment and switch in seenFromCommandLine: 279 raise ValueError("duplicate switch on command line: " + switch) 280 # if we're parsing from the environment, and we've already seen the switch in the environment, this is an error 281 elif isFromEnvironment and switch in seenFromEnvironment: 282 raise ValueError("duplicate switch from environment: " + switch) 283 # if we're parsing from the environment, and we've already seen the switch on the command line, skip it, since command line takes precedence 284 elif isFromEnvironment and switch in seenFromCommandLine: 285 continue 286 # otherwise, the switch is new, so add it to the appropriate set 287 else: 288 if isFromEnvironment: 289 seenFromEnvironment.add(switch) 290 else: 291 seenFromCommandLine.add(switch) 292 293 # process the switch, running the corresponding action 294 try: 295 action(arg, iterator) 296 except StopIteration: 297 assert placeholder is not None 298 raise ValueError("{0}: missing {1}".format(switch, placeholder)) 299 except Exception as exc: 300 raise ValueError("invalid {0} {1}: {2}".format(switch, placeholder, exc)) 301 302 # If we're parsing the command line, we're done after we've processed the target 303 # Otherwise, we need to keep parsing until all args are consumed, since the target may be set from the command line 304 # already, but there might be additional args in the environment that we want to process. 305 if not isFromEnvironment and options.target is not None: 306 break 307 308 309 def start_debugging(argv_0): 310 # We need to set up sys.argv[0] before invoking either listen() or connect(), 311 # because they use it to report the "process" event. Thus, we can't rely on 312 # run_path() and run_module() doing that, even though they will eventually. 313 sys.argv[0] = argv_0 314 315 log.debug("sys.argv after patching: {0!r}", sys.argv) 316 317 debugpy.configure(options.config) 318 319 if os.environ.get("DEBUGPY_RUNNING", "false") != "true": 320 if options.mode == "listen" and options.address is not None: 321 debugpy.listen(options.address) 322 elif options.mode == "connect" and options.address is not None: 323 debugpy.connect(options.address, access_token=options.adapter_access_token) 324 else: 325 raise AssertionError(repr(options.mode)) 326 327 if options.wait_for_client: 328 debugpy.wait_for_client() 329 330 os.environ["DEBUGPY_RUNNING"] = "true" 331 332 333 def run_file(): 334 target = options.target 335 start_debugging(target) 336 337 # run_path has one difference with invoking Python from command-line: 338 # if the target is a file (rather than a directory), it does not add its 339 # parent directory to sys.path. Thus, importing other modules from the 340 # same directory is broken unless sys.path is patched here. 341 342 if target is not None and os.path.isfile(target): 343 dir = os.path.dirname(target) 344 sys.path.insert(0, dir) 345 else: 346 log.debug("Not a file: {0!r}", target) 347 348 log.describe_environment("Pre-launch environment:") 349 350 log.info("Running file {0!r}", target) 351 runpy.run_path(target, run_name="__main__") 352 353 354 def run_module(): 355 # Add current directory to path, like Python itself does for -m. This must 356 # be in place before trying to use find_spec below to resolve submodules. 357 sys.path.insert(0, str("")) 358 359 # We want to do the same thing that run_module() would do here, without 360 # actually invoking it. 361 argv_0 = sys.argv[0] 362 try: 363 spec = None if options.target is None else find_spec(options.target) 364 if spec is not None: 365 argv_0 = spec.origin 366 except Exception: 367 log.swallow_exception("Error determining module path for sys.argv") 368 369 start_debugging(argv_0) 370 log.describe_environment("Pre-launch environment:") 371 log.info("Running module {0!r}", options.target) 372 373 # Docs say that runpy.run_module is equivalent to -m, but it's not actually 374 # the case for packages - -m sets __name__ to "__main__", but run_module sets 375 # it to "pkg.__main__". This breaks everything that uses the standard pattern 376 # __name__ == "__main__" to detect being run as a CLI app. On the other hand, 377 # runpy._run_module_as_main is a private function that actually implements -m. 378 try: 379 run_module_as_main = runpy._run_module_as_main 380 except AttributeError: 381 log.warning("runpy._run_module_as_main is missing, falling back to run_module.") 382 runpy.run_module(options.target, alter_sys=True) 383 else: 384 run_module_as_main(options.target, alter_argv=True) 385 386 387 def run_code(): 388 if options.target is not None: 389 # Add current directory to path, like Python itself does for -c. 390 sys.path.insert(0, str("")) 391 code = compile(options.target, str("<string>"), str("exec")) 392 393 start_debugging(str("-c")) 394 395 log.describe_environment("Pre-launch environment:") 396 log.info("Running code:\n\n{0}", options.target) 397 398 eval(code, {}) 399 else: 400 log.error("No target to run.") 401 402 403 def attach_to_pid(): 404 pid = options.target 405 log.info("Attaching to process with PID={0}", pid) 406 407 encode = lambda s: list(bytearray(s.encode("utf-8"))) if s is not None else None 408 409 script_dir = os.path.dirname(debugpy.server.__file__) 410 assert os.path.exists(script_dir) 411 script_dir = encode(script_dir) 412 413 setup = { 414 "mode": options.mode, 415 "address": options.address, 416 "wait_for_client": options.wait_for_client, 417 "log_to": options.log_to, 418 "adapter_access_token": options.adapter_access_token, 419 } 420 setup = encode(json.dumps(setup)) 421 422 python_code = """ 423 import codecs; 424 import json; 425 import sys; 426 427 decode = lambda s: codecs.utf_8_decode(bytearray(s))[0] if s is not None else None; 428 429 script_dir = decode({script_dir}); 430 setup = json.loads(decode({setup})); 431 432 sys.path.insert(0, script_dir); 433 import attach_pid_injected; 434 del sys.path[0]; 435 436 attach_pid_injected.attach(setup); 437 """ 438 python_code = ( 439 python_code.replace("\r", "") 440 .replace("\n", "") 441 .format(script_dir=script_dir, setup=setup) 442 ) 443 log.info("Code to be injected: \n{0}", python_code.replace(";", ";\n")) 444 445 # pydevd restriction on characters in injected code. 446 assert not ( 447 {'"', "'", "\r", "\n"} & set(python_code) 448 ), "Injected code should not contain any single quotes, double quotes, or newlines." 449 450 pydevd_attach_to_process_path = os.path.join( 451 os.path.dirname(pydevd.__file__), "pydevd_attach_to_process" 452 ) 453 454 assert os.path.exists(pydevd_attach_to_process_path) 455 sys.path.append(pydevd_attach_to_process_path) 456 457 try: 458 import add_code_to_python_process # noqa 459 460 log.info("Injecting code into process with PID={0} ...", pid) 461 add_code_to_python_process.run_python_code( 462 pid, 463 python_code, 464 connect_debugger_tracing=True, 465 show_debug_info=int(os.getenv("DEBUGPY_ATTACH_BY_PID_DEBUG_INFO", "0")), 466 ) 467 except Exception: 468 log.reraise_exception("Code injection into PID={0} failed:", pid) 469 log.info("Code injection into PID={0} completed.", pid) 470 471 472 def main(): 473 original_argv = list(sys.argv) 474 try: 475 parse_args() 476 except Exception as exc: 477 print(str(HELP) + str("\nError: ") + str(exc), file=sys.stderr) 478 sys.exit(2) 479 480 if options.log_to is not None: 481 debugpy.log_to(options.log_to) 482 if options.log_to_stderr: 483 debugpy.log_to(sys.stderr) 484 485 api.ensure_logging() 486 487 log.info( 488 str("sys.argv before parsing: {0!r}\n" " after parsing: {1!r}"), 489 original_argv, 490 sys.argv, 491 ) 492 493 try: 494 if options.target_kind is not None: 495 run = { 496 "file": run_file, 497 "module": run_module, 498 "code": run_code, 499 "pid": attach_to_pid, 500 }[options.target_kind] 501 run() 502 except SystemExit as exc: 503 log.reraise_exception( 504 "Debuggee exited via SystemExit: {0!r}", exc.code, level="debug" 505 ) 506