Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions src/debugpy/server/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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():
target_dir = os.path.dirname(target)
sys.path.insert(0, target_dir)
else:
log.debug("Not a file: {0!r}", target)

Expand All @@ -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.
Expand Down Expand Up @@ -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("<string>"), str("exec"))

start_debugging(str("-c"))
Expand Down
95 changes: 95 additions & 0 deletions tests/debugpy/test_cli_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -33,3 +39,92 @@ 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
)
)

# Use subprocess.run so we can set an explicit timeout and keep stderr for
# diagnostics on Windows py312, where the CLI child occasionally hangs or
# prints useful warnings on the error stream.
try:
completed = subprocess.run(
[sys.executable, python_flag, "-c", wrapper],
cwd=tmpdir.strpath,
capture_output=True,
timeout=30,
check=True,
)
except subprocess.TimeoutExpired as e:
pytest.fail(
"debugpy CLI timed out after 30s.\n"
"stdout so far:\n"
f"{(e.stdout or b'').decode('utf-8', errors='replace')}\n"
"stderr so far:\n"
f"{(e.stderr or b'').decode('utf-8', errors='replace')}"
)
except subprocess.CalledProcessError as e:
pytest.fail(
f"debugpy CLI exited with code {e.returncode}.\n"
"stdout:\n"
f"{(e.stdout or b'').decode('utf-8', errors='replace')}\n"
"stderr:\n"
f"{(e.stderr or b'').decode('utf-8', errors='replace')}"
)

stderr_text = (
completed.stderr.decode("utf-8", errors="replace") if completed.stderr else ""
)

assert completed.stdout, (
f"Subprocess produced no stdout. returncode={completed.returncode}\n"
f"stderr:\n{stderr_text}"
)
sys_path_0 = completed.stdout.decode("utf-8").strip().splitlines()[-1]
assert sys_path_0 != repr(not_expected), (
f"Expected sys.path[0] != {not_expected!r}, got {sys_path_0!r}.\n"
f"stderr:\n{stderr_text}"
)
Loading