diff --git a/src/debugpy/server/cli.py b/src/debugpy/server/cli.py index aa506e0c..1eede7d0 100644 --- a/src/debugpy/server/cli.py +++ b/src/debugpy/server/cli.py @@ -340,6 +340,14 @@ def start_debugging(argv_0): os.environ["DEBUGPY_RUNNING"] = "true" +def _skip_sys_path_prepend(): + # Python does not prepend the script's parent directory (for a file target) + # or the current directory (for -m / -c) to sys.path in isolated mode (-I) or + # safe-path mode (-P / PYTHONSAFEPATH), so debugpy shouldn't either. + # `sys.flags.safe_path` was added in Python 3.11; fall back to False on older versions. + return sys.flags.isolated or getattr(sys.flags, "safe_path", False) + + def run_file(): target = options.target start_debugging(target) @@ -348,10 +356,13 @@ def run_file(): # if the target is a file (rather than a directory), it does not add its # parent directory to sys.path. Thus, importing other modules from the # same directory is broken unless sys.path is patched here. + # In isolated / safe-path mode, Python itself doesn't add the script directory, + # so don't do it here either. if target is not None and os.path.isfile(target): - dir = os.path.dirname(target) - sys.path.insert(0, dir) + if not _skip_sys_path_prepend(): + dir = os.path.dirname(target) + sys.path.insert(0, dir) else: log.debug("Not a file: {0!r}", target) @@ -362,9 +373,11 @@ def run_file(): def run_module(): - # Add current directory to path, like Python itself does for -m. This must - # be in place before trying to use find_spec below to resolve submodules. - sys.path.insert(0, str("")) + # Add current directory to path, like Python itself does for -m, unless + # it's suppressed by isolated / safe-path mode. This must be in place before + # trying to use find_spec below to resolve submodules. + if not _skip_sys_path_prepend(): + sys.path.insert(0, str("")) # We want to do the same thing that run_module() would do here, without # actually invoking it. @@ -396,8 +409,10 @@ def run_module(): def run_code(): if options.target is not None: - # Add current directory to path, like Python itself does for -c. - sys.path.insert(0, str("")) + # Add current directory to path, like Python itself does for -c, + # unless it's suppressed by isolated / safe-path mode. + if not _skip_sys_path_prepend(): + sys.path.insert(0, str("")) code = compile(options.target, str(""), str("exec")) start_debugging(str("-c")) diff --git a/tests/debugpy/test_cli_args.py b/tests/debugpy/test_cli_args.py index e7a55d5e..5b7893ec 100644 --- a/tests/debugpy/test_cli_args.py +++ b/tests/debugpy/test_cli_args.py @@ -2,6 +2,12 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. +import os +import subprocess +import sys + +import pytest + from tests import debug @@ -33,3 +39,59 @@ def code_to_debug(): cli_options = backchannel.receive() assert cli_options['mode'] == 'connect' assert cli_options['target_kind'] == 'file' + + +@pytest.mark.parametrize("target_kind", ["file", "module", "code"]) +@pytest.mark.parametrize("python_flag", ["-I", "-P"]) +def test_safe_sys_path_modes(pyfile, tmpdir, target_kind, python_flag): + # In isolated mode (-I) and safe-path mode (-P / PYTHONSAFEPATH), Python does + # not prepend the script directory (for a file target) or the current directory + # (for -m and -c) to sys.path, and neither should debugpy. + # https://github.com/microsoft/debugpy/issues/1916 + if python_flag == "-P" and sys.version_info < (3, 11): + pytest.skip("-P / PYTHONSAFEPATH requires Python 3.11+") + + import debugpy + + @pyfile + def code_to_debug(): + import sys + + print(repr(sys.path[0])) + + debugpy_root = os.path.dirname(os.path.dirname(os.path.abspath(debugpy.__file__))) + + code = "import sys; print(repr(sys.path[0]))" + cli_args = ["--listen", "127.0.0.1:0"] + if target_kind == "file": + cli_args += [code_to_debug.strpath] + not_expected = os.path.dirname(code_to_debug.strpath) + elif target_kind == "module": + tmpdir.join("debuggee_module.py").write(code) + cli_args += ["-m", "debuggee_module"] + not_expected = "" + else: + cli_args += ["-c", code] + not_expected = "" + + # -I / -P do not block explicit sys.path edits, so make debugpy (and the module + # target) importable by inserting their locations before invoking the CLI, the + # same way they would be resolved from site-packages of an installed copy. + wrapper = ( + "import sys; " + "sys.path.insert(0, {tmpdir!r}); " + "sys.path.insert(0, {debugpy_root!r}); " + "sys.argv[1:] = {cli_args!r}; " + "from debugpy.server import cli; cli.main()".format( + tmpdir=tmpdir.strpath, debugpy_root=debugpy_root, cli_args=cli_args + ) + ) + + output = subprocess.check_output( + [sys.executable, python_flag, "-c", wrapper], + cwd=tmpdir.strpath, + stderr=subprocess.DEVNULL, + ) + + sys_path_0 = output.decode("utf-8").strip().splitlines()[-1] + assert sys_path_0 != repr(not_expected)