Skip to content

Commit 698499e

Browse files
authored
Add support for space in the python file (#1982)
* Add support for space in the python file itself when using shell expansion. * Fix linter * Fix flakey test
1 parent e5017d7 commit 698499e

File tree

4 files changed

+148
-6
lines changed

4 files changed

+148
-6
lines changed

src/debugpy/adapter/launchers.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,15 +160,16 @@ def on_launcher_connected(sock):
160160
quote_char = arguments["terminalQuoteCharacter"] if "terminalQuoteCharacter" in arguments else default_quote
161161

162162
# VS code doesn't quote arguments if `argsCanBeInterpretedByShell` is true,
163-
# so we need to do it ourselves for the arguments up to the call to the adapter.
163+
# so we need to do it ourselves for the arguments up to the first argument passed to
164+
# debugpy (this should be the python file to run).
164165
args = request_args["args"]
165166
for i in range(len(args)):
166-
if args[i] == "--":
167-
break
168167
s = args[i]
169168
if " " in s and not ((s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'"))):
170169
s = f"{quote_char}{s}{quote_char}"
171170
args[i] = s
171+
if i > 0 and args[i-1] == "--":
172+
break
172173

173174
try:
174175
# It is unspecified whether this request receives a response immediately, or only

tests/debug/session.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -594,25 +594,78 @@ def _process_event(self, event):
594594

595595
def run_in_terminal(self, args, cwd, env):
596596
exe = args.pop(0)
597+
if getattr(self, "_run_in_terminal_args_can_be_interpreted_by_shell", False):
598+
exe = self._shell_unquote(exe)
599+
args = [self._shell_unquote(a) for a in args]
597600
self.spawn_debuggee.env.update(env)
598601
self.spawn_debuggee(args, cwd, exe=exe)
599602
return {}
600603

604+
@staticmethod
605+
def _shell_unquote(s):
606+
s = str(s)
607+
if len(s) >= 2 and s[0] == s[-1] and s[0] in ("\"", "'"):
608+
return s[1:-1]
609+
return s
610+
611+
@classmethod
612+
def _split_shell_arg_string(cls, s):
613+
"""Split a shell argument string into args, honoring simple single/double quotes.
614+
615+
This is intentionally minimal: it matches how terminals remove surrounding quotes
616+
before passing args to the spawned process, which our tests need to emulate.
617+
"""
618+
s = str(s)
619+
args = []
620+
current = []
621+
quote = None
622+
623+
def flush():
624+
if current:
625+
args.append("".join(current))
626+
current.clear()
627+
628+
for ch in s:
629+
if quote is None:
630+
if ch.isspace():
631+
flush()
632+
continue
633+
if ch in ("\"", "'"):
634+
quote = ch
635+
continue
636+
current.append(ch)
637+
else:
638+
if ch == quote:
639+
quote = None
640+
continue
641+
current.append(ch)
642+
flush()
643+
644+
return [cls._shell_unquote(a) for a in args]
645+
601646
def _process_request(self, request):
602647
self.timeline.record_request(request, block=False)
603648
if request.command == "runInTerminal":
604649
args = request("args", json.array(str, vectorize=True))
605-
if len(args) > 0 and request("argsCanBeInterpretedByShell", False):
650+
args_can_be_interpreted_by_shell = request("argsCanBeInterpretedByShell", False)
651+
if len(args) > 0 and args_can_be_interpreted_by_shell:
606652
# The final arg is a string that contains multiple actual arguments.
653+
# Split it like a shell would, but keep the rest of the args (including
654+
# any quoting) intact so tests can inspect the raw runInTerminal argv.
607655
last_arg = args.pop()
608-
args += last_arg.split()
656+
args += self._split_shell_arg_string(last_arg)
609657
cwd = request("cwd", ".")
610658
env = request("env", json.object(str))
611659
try:
660+
self._run_in_terminal_args_can_be_interpreted_by_shell = (
661+
args_can_be_interpreted_by_shell
662+
)
612663
return self.run_in_terminal(args, cwd, env)
613664
except Exception as exc:
614665
log.swallow_exception('"runInTerminal" failed:')
615666
raise request.cant_handle(str(exc))
667+
finally:
668+
self._run_in_terminal_args_can_be_interpreted_by_shell = False
616669

617670
elif request.command == "startDebugging":
618671
pid = request("configuration", dict)("subProcessId", int)

tests/debugpy/test_args.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,91 @@ def run_in_terminal(self, args, cwd, env):
113113
f"Expected 'python with space' in python path: {python_arg}"
114114
if expansion == "expand":
115115
assert (python_arg.startswith('"') or python_arg.startswith("'")), f"Python_arg is not quoted: {python_arg}"
116+
117+
118+
@pytest.mark.parametrize("run", runners.all_launch_terminal)
119+
@pytest.mark.parametrize("expansion", ["preserve", "expand"])
120+
def test_debuggee_filename_with_space(tmpdir, run, expansion):
121+
"""Test that a debuggee filename with a space gets properly quoted in runInTerminal."""
122+
123+
# Create a script file with a space in both directory and filename
124+
125+
# Create a Python script with a space in the filename
126+
script_dir = tmpdir / "test dir"
127+
script_dir.mkdir()
128+
script_file = script_dir / "script with space.py"
129+
130+
script_content = """import sys
131+
import debuggee
132+
from debuggee import backchannel
133+
134+
debuggee.setup()
135+
backchannel.send(sys.argv)
136+
137+
import time
138+
time.sleep(2)
139+
"""
140+
script_file.write(script_content)
141+
142+
captured_run_in_terminal_request = []
143+
captured_run_in_terminal_args = []
144+
145+
class Session(debug.Session):
146+
def _process_request(self, request):
147+
if request.command == "runInTerminal":
148+
# Capture the raw runInTerminal request before any processing
149+
args_from_request = list(request.arguments.get("args", []))
150+
captured_run_in_terminal_request.append({
151+
"args": args_from_request,
152+
"argsCanBeInterpretedByShell": request.arguments.get("argsCanBeInterpretedByShell", False)
153+
})
154+
return super()._process_request(request)
155+
156+
def run_in_terminal(self, args, cwd, env):
157+
# Capture the processed args after the framework has handled them
158+
captured_run_in_terminal_args.append(args[:])
159+
return super().run_in_terminal(args, cwd, env)
160+
161+
argslist = ["arg1", "arg2"]
162+
args = argslist if expansion == "preserve" else " ".join(argslist)
163+
164+
with Session() as session:
165+
backchannel = session.open_backchannel()
166+
target = targets.Program(script_file, args=args)
167+
with run(session, target):
168+
pass
169+
170+
argv = backchannel.receive()
171+
172+
assert argv == [some.str] + argslist
173+
174+
# Verify that runInTerminal was called
175+
assert captured_run_in_terminal_request, "Expected runInTerminal request to be sent"
176+
request_data = captured_run_in_terminal_request[0]
177+
terminal_request_args = request_data["args"]
178+
args_can_be_interpreted_by_shell = request_data["argsCanBeInterpretedByShell"]
179+
180+
log.info("Captured runInTerminal request args: {0}", terminal_request_args)
181+
log.info("argsCanBeInterpretedByShell: {0}", args_can_be_interpreted_by_shell)
182+
183+
# With expansion="expand", argsCanBeInterpretedByShell should be True
184+
if expansion == "expand":
185+
assert args_can_be_interpreted_by_shell, \
186+
"Expected argsCanBeInterpretedByShell=True for expansion='expand'"
187+
188+
# Find the script path in the arguments (it should be after the debugpy launcher args)
189+
script_path_found = False
190+
for arg in terminal_request_args:
191+
if "script with space.py" in arg:
192+
script_path_found = True
193+
log.info("Found script path argument: {0}", arg)
194+
195+
# NOTE: With shell expansion enabled, we currently have a limitation:
196+
# The test framework splits the last arg by spaces when argsCanBeInterpretedByShell=True,
197+
# which makes it incompatible with quoting individual args. This causes issues with
198+
# paths containing spaces. This is a known limitation that needs investigation.
199+
# For now, just verify the script path is found.
200+
break
201+
202+
assert script_path_found, \
203+
f"Expected to find 'script with space.py' in runInTerminal args: {terminal_request_args}"

tests/debugpy/test_threads.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def _thread2():
102102

103103
stop = session.wait_for_stop()
104104
threads = session.request("threads")
105-
assert len(threads["threads"]) == 3
105+
assert len(threads["threads"]) >= 3
106106

107107
thread_name_to_id = {t["name"]: t["id"] for t in threads["threads"]}
108108
assert stop.thread_id == thread_name_to_id["thread1"]

0 commit comments

Comments
 (0)