1use crate::*;
2use anyhow::{Context as _, bail};
3use collections::HashMap;
4use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
5use fs::RemoveOptions;
6use futures::{StreamExt, TryStreamExt};
7use gpui::http_client::AsyncBody;
8use gpui::{AsyncApp, SharedString};
9use json_dotpath::DotPaths;
10use language::{LanguageName, Toolchain};
11use paths::debug_adapters_dir;
12use serde_json::Value;
13use smol::fs::File;
14use smol::io::AsyncReadExt;
15use smol::lock::OnceCell;
16use std::ffi::OsString;
17use std::net::Ipv4Addr;
18use std::str::FromStr;
19use std::{
20 ffi::OsStr,
21 path::{Path, PathBuf},
22};
23use util::command::new_smol_command;
24use util::{ResultExt, paths::PathStyle, rel_path::RelPath};
25
26#[derive(Default)]
27pub(crate) struct PythonDebugAdapter {
28 base_venv_path: OnceCell<Result<Arc<Path>, String>>,
29 debugpy_whl_base_path: OnceCell<Result<Arc<Path>, String>>,
30}
31
32impl PythonDebugAdapter {
33 const ADAPTER_NAME: &'static str = "Debugpy";
34 const DEBUG_ADAPTER_NAME: DebugAdapterName =
35 DebugAdapterName(SharedString::new_static(Self::ADAPTER_NAME));
36
37 const LANGUAGE_NAME: &'static str = "Python";
38
39 async fn generate_debugpy_arguments(
40 host: &Ipv4Addr,
41 port: u16,
42 user_installed_path: Option<&Path>,
43 user_args: Option<Vec<String>>,
44 ) -> Result<Vec<String>> {
45 let mut args = if let Some(user_installed_path) = user_installed_path {
46 log::debug!(
47 "Using user-installed debugpy adapter from: {}",
48 user_installed_path.display()
49 );
50 vec![user_installed_path.to_string_lossy().into_owned()]
51 } else {
52 let adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
53 let path = adapter_path
54 .join("debugpy")
55 .join("adapter")
56 .to_string_lossy()
57 .into_owned();
58 log::debug!("Using pip debugpy adapter from: {path}");
59 vec![path]
60 };
61
62 args.extend(if let Some(args) = user_args {
63 args
64 } else {
65 vec![format!("--host={}", host), format!("--port={}", port)]
66 });
67 Ok(args)
68 }
69
70 async fn request_args(
71 &self,
72 delegate: &Arc<dyn DapDelegate>,
73 task_definition: &DebugTaskDefinition,
74 ) -> Result<StartDebuggingRequestArguments> {
75 let request = self.request_kind(&task_definition.config).await?;
76
77 let mut configuration = task_definition.config.clone();
78 if let Ok(console) = configuration.dot_get_mut("console") {
79 // Use built-in Zed terminal if user did not explicitly provide a setting for console.
80 if console.is_null() {
81 *console = Value::String("integratedTerminal".into());
82 }
83 }
84
85 if let Some(obj) = configuration.as_object_mut() {
86 obj.entry("cwd")
87 .or_insert(delegate.worktree_root_path().to_string_lossy().into());
88 }
89
90 Ok(StartDebuggingRequestArguments {
91 configuration,
92 request,
93 })
94 }
95
96 async fn fetch_wheel(
97 &self,
98 toolchain: Option<Toolchain>,
99 delegate: &Arc<dyn DapDelegate>,
100 ) -> Result<Arc<Path>> {
101 let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME).join("wheels");
102 std::fs::create_dir_all(&download_dir)?;
103 let venv_python = self.base_venv_path(toolchain, delegate).await?;
104
105 let installation_succeeded = util::command::new_smol_command(venv_python.as_ref())
106 .args([
107 "-m",
108 "pip",
109 "download",
110 "debugpy",
111 "--only-binary=:all:",
112 "-d",
113 download_dir.to_string_lossy().as_ref(),
114 ])
115 .output()
116 .await
117 .context("spawn system python")?
118 .status
119 .success();
120 if !installation_succeeded {
121 bail!("debugpy installation failed (could not fetch Debugpy's wheel)");
122 }
123
124 let wheel_path = std::fs::read_dir(&download_dir)?
125 .find_map(|entry| {
126 entry.ok().filter(|e| {
127 e.file_type().is_ok_and(|typ| typ.is_file())
128 && Path::new(&e.file_name()).extension() == Some("whl".as_ref())
129 })
130 })
131 .with_context(|| format!("Did not find a .whl in {download_dir:?}"))?;
132
133 util::archive::extract_zip(
134 &debug_adapters_dir().join(Self::ADAPTER_NAME),
135 File::open(&wheel_path.path()).await?,
136 )
137 .await?;
138
139 Ok(Arc::from(wheel_path.path()))
140 }
141
142 async fn maybe_fetch_new_wheel(
143 &self,
144 toolchain: Option<Toolchain>,
145 delegate: &Arc<dyn DapDelegate>,
146 ) -> Result<()> {
147 let latest_release = delegate
148 .http_client()
149 .get(
150 "https://pypi.org/pypi/debugpy/json",
151 AsyncBody::empty(),
152 false,
153 )
154 .await
155 .log_err();
156 let response = latest_release
157 .filter(|response| response.status().is_success())
158 .context("getting latest release")?;
159
160 let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME);
161 std::fs::create_dir_all(&download_dir)?;
162
163 let mut output = String::new();
164 response.into_body().read_to_string(&mut output).await?;
165 let as_json = serde_json::Value::from_str(&output)?;
166 let latest_version = as_json
167 .get("info")
168 .and_then(|info| {
169 info.get("version")
170 .and_then(|version| version.as_str())
171 .map(ToOwned::to_owned)
172 })
173 .context("parsing latest release information")?;
174 let dist_info_dirname: OsString = format!("debugpy-{latest_version}.dist-info").into();
175 let is_up_to_date = delegate
176 .fs()
177 .read_dir(&debug_adapters_dir().join(Self::ADAPTER_NAME))
178 .await?
179 .into_stream()
180 .any(async |entry| {
181 entry.is_ok_and(|e| e.file_name().is_some_and(|name| name == dist_info_dirname))
182 })
183 .await;
184
185 if !is_up_to_date {
186 delegate
187 .fs()
188 .remove_dir(
189 &debug_adapters_dir().join(Self::ADAPTER_NAME),
190 RemoveOptions {
191 recursive: true,
192 ignore_if_not_exists: true,
193 },
194 )
195 .await?;
196 self.fetch_wheel(toolchain, delegate).await?;
197 }
198 anyhow::Ok(())
199 }
200
201 async fn fetch_debugpy_whl(
202 &self,
203 toolchain: Option<Toolchain>,
204 delegate: &Arc<dyn DapDelegate>,
205 ) -> Result<Arc<Path>, String> {
206 self.debugpy_whl_base_path
207 .get_or_init(|| async move {
208 self.maybe_fetch_new_wheel(toolchain, delegate)
209 .await
210 .map_err(|e| format!("{e}"))?;
211 Ok(Arc::from(
212 debug_adapters_dir()
213 .join(Self::ADAPTER_NAME)
214 .join("debugpy")
215 .join("adapter")
216 .as_ref(),
217 ))
218 })
219 .await
220 .clone()
221 }
222
223 async fn base_venv_path(
224 &self,
225 toolchain: Option<Toolchain>,
226 delegate: &Arc<dyn DapDelegate>,
227 ) -> Result<Arc<Path>> {
228 let result = self.base_venv_path
229 .get_or_init(|| async {
230 let base_python = if let Some(toolchain) = toolchain {
231 toolchain.path.to_string()
232 } else {
233 Self::system_python_name(delegate).await.ok_or_else(|| {
234 let mut message = "Could not find a Python installation".to_owned();
235 if cfg!(windows){
236 message.push_str(". Install Python from the Microsoft Store, or manually from https://www.python.org/downloads/windows.")
237 }
238 message
239 })?
240 };
241
242 let debug_adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
243 let output = util::command::new_smol_command(&base_python)
244 .args(["-m", "venv", "zed_base_venv"])
245 .current_dir(
246 &debug_adapter_path,
247 )
248 .spawn()
249 .map_err(|e| format!("{e:#?}"))?
250 .output()
251 .await
252 .map_err(|e| format!("{e:#?}"))?;
253
254 if !output.status.success() {
255 let stderr = String::from_utf8_lossy(&output.stderr);
256 let stdout = String::from_utf8_lossy(&output.stdout);
257 let debug_adapter_path = debug_adapter_path.display();
258 return Err(format!("Failed to create base virtual environment with {base_python} in:\n{debug_adapter_path}\nstderr:\n{stderr}\nstdout:\n{stdout}\n"));
259 }
260
261 const PYTHON_PATH: &str = if cfg!(target_os = "windows") {
262 "Scripts/python.exe"
263 } else {
264 "bin/python3"
265 };
266 Ok(Arc::from(
267 paths::debug_adapters_dir()
268 .join(Self::DEBUG_ADAPTER_NAME.as_ref())
269 .join("zed_base_venv")
270 .join(PYTHON_PATH)
271 .as_ref(),
272 ))
273 })
274 .await
275 .clone();
276 match result {
277 Ok(path) => Ok(path),
278 Err(e) => Err(anyhow::anyhow!("{e}")),
279 }
280 }
281 async fn system_python_name(delegate: &Arc<dyn DapDelegate>) -> Option<String> {
282 const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
283 let mut name = None;
284
285 for cmd in BINARY_NAMES {
286 let Some(path) = delegate.which(OsStr::new(cmd)).await else {
287 continue;
288 };
289 // Try to detect situations where `python3` exists but is not a real Python interpreter.
290 // Notably, on fresh Windows installs, `python3` is a shim that opens the Microsoft Store app
291 // when run with no arguments, and just fails otherwise.
292 let Some(output) = new_smol_command(&path)
293 .args(["-c", "print(1 + 2)"])
294 .output()
295 .await
296 .ok()
297 else {
298 continue;
299 };
300 if output.stdout.trim_ascii() != b"3" {
301 continue;
302 }
303 name = Some(path.to_string_lossy().into_owned());
304 break;
305 }
306 name
307 }
308
309 async fn get_installed_binary(
310 &self,
311 delegate: &Arc<dyn DapDelegate>,
312 config: &DebugTaskDefinition,
313 user_installed_path: Option<PathBuf>,
314 user_args: Option<Vec<String>>,
315 user_env: Option<HashMap<String, String>>,
316 python_from_toolchain: Option<String>,
317 ) -> Result<DebugAdapterBinary> {
318 let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
319 let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
320
321 let python_path = if let Some(toolchain) = python_from_toolchain {
322 Some(toolchain)
323 } else {
324 Self::system_python_name(delegate).await
325 };
326
327 let python_command = python_path.context("failed to find binary path for Python")?;
328 log::debug!("Using Python executable: {}", python_command);
329
330 let arguments = Self::generate_debugpy_arguments(
331 &host,
332 port,
333 user_installed_path.as_deref(),
334 user_args,
335 )
336 .await?;
337
338 log::debug!(
339 "Starting debugpy adapter with command: {} {}",
340 python_command,
341 arguments.join(" ")
342 );
343
344 Ok(DebugAdapterBinary {
345 command: Some(python_command),
346 arguments,
347 connection: Some(adapters::TcpArguments {
348 host,
349 port,
350 timeout,
351 }),
352 cwd: Some(delegate.worktree_root_path().to_path_buf()),
353 envs: user_env.unwrap_or_default(),
354 request_args: self.request_args(delegate, config).await?,
355 })
356 }
357}
358
359#[async_trait(?Send)]
360impl DebugAdapter for PythonDebugAdapter {
361 fn name(&self) -> DebugAdapterName {
362 Self::DEBUG_ADAPTER_NAME
363 }
364
365 fn adapter_language_name(&self) -> Option<LanguageName> {
366 Some(SharedString::new_static("Python").into())
367 }
368
369 async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
370 let mut args = json!({
371 "request": match zed_scenario.request {
372 DebugRequest::Launch(_) => "launch",
373 DebugRequest::Attach(_) => "attach",
374 },
375 "subProcess": true,
376 "redirectOutput": true,
377 });
378
379 let map = args.as_object_mut().unwrap();
380 match &zed_scenario.request {
381 DebugRequest::Attach(attach) => {
382 map.insert("processId".into(), attach.process_id.into());
383 }
384 DebugRequest::Launch(launch) => {
385 map.insert("program".into(), launch.program.clone().into());
386 map.insert("args".into(), launch.args.clone().into());
387 if !launch.env.is_empty() {
388 map.insert("env".into(), launch.env_json());
389 }
390
391 if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
392 map.insert("stopOnEntry".into(), stop_on_entry.into());
393 }
394 if let Some(cwd) = launch.cwd.as_ref() {
395 map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
396 }
397 }
398 }
399
400 Ok(DebugScenario {
401 adapter: zed_scenario.adapter,
402 label: zed_scenario.label,
403 config: args,
404 build: None,
405 tcp_connection: None,
406 })
407 }
408
409 fn dap_schema(&self) -> serde_json::Value {
410 json!({
411 "properties": {
412 "request": {
413 "type": "string",
414 "enum": ["attach", "launch"],
415 "description": "Debug adapter request type"
416 },
417 "autoReload": {
418 "default": {},
419 "description": "Configures automatic reload of code on edit.",
420 "properties": {
421 "enable": {
422 "default": false,
423 "description": "Automatically reload code on edit.",
424 "type": "boolean"
425 },
426 "exclude": {
427 "default": [
428 "**/.git/**",
429 "**/.metadata/**",
430 "**/__pycache__/**",
431 "**/node_modules/**",
432 "**/site-packages/**"
433 ],
434 "description": "Glob patterns of paths to exclude from auto reload.",
435 "items": {
436 "type": "string"
437 },
438 "type": "array"
439 },
440 "include": {
441 "default": [
442 "**/*.py",
443 "**/*.pyw"
444 ],
445 "description": "Glob patterns of paths to include in auto reload.",
446 "items": {
447 "type": "string"
448 },
449 "type": "array"
450 }
451 },
452 "type": "object"
453 },
454 "debugAdapterPath": {
455 "description": "Path (fully qualified) to the python debug adapter executable.",
456 "type": "string"
457 },
458 "django": {
459 "default": false,
460 "description": "Django debugging.",
461 "type": "boolean"
462 },
463 "jinja": {
464 "default": null,
465 "description": "Jinja template debugging (e.g. Flask).",
466 "enum": [
467 false,
468 null,
469 true
470 ]
471 },
472 "justMyCode": {
473 "default": true,
474 "description": "If true, show and debug only user-written code. If false, show and debug all code, including library calls.",
475 "type": "boolean"
476 },
477 "logToFile": {
478 "default": false,
479 "description": "Enable logging of debugger events to a log file. This file can be found in the debugpy extension install folder.",
480 "type": "boolean"
481 },
482 "pathMappings": {
483 "default": [],
484 "items": {
485 "label": "Path mapping",
486 "properties": {
487 "localRoot": {
488 "default": "${ZED_WORKTREE_ROOT}",
489 "label": "Local source root.",
490 "type": "string"
491 },
492 "remoteRoot": {
493 "default": "",
494 "label": "Remote source root.",
495 "type": "string"
496 }
497 },
498 "required": [
499 "localRoot",
500 "remoteRoot"
501 ],
502 "type": "object"
503 },
504 "label": "Path mappings.",
505 "type": "array"
506 },
507 "redirectOutput": {
508 "default": true,
509 "description": "Redirect output.",
510 "type": "boolean"
511 },
512 "showReturnValue": {
513 "default": true,
514 "description": "Show return value of functions when stepping.",
515 "type": "boolean"
516 },
517 "subProcess": {
518 "default": false,
519 "description": "Whether to enable Sub Process debugging",
520 "type": "boolean"
521 },
522 "consoleName": {
523 "default": "Python Debug Console",
524 "description": "Display name of the debug console or terminal",
525 "type": "string"
526 },
527 "clientOS": {
528 "default": null,
529 "description": "OS that VS code is using.",
530 "enum": [
531 "windows",
532 null,
533 "unix"
534 ]
535 }
536 },
537 "required": ["request"],
538 "allOf": [
539 {
540 "if": {
541 "properties": {
542 "request": {
543 "enum": ["attach"]
544 }
545 }
546 },
547 "then": {
548 "properties": {
549 "connect": {
550 "label": "Attach by connecting to debugpy over a socket.",
551 "properties": {
552 "host": {
553 "default": "127.0.0.1",
554 "description": "Hostname or IP address to connect to.",
555 "type": "string"
556 },
557 "port": {
558 "description": "Port to connect to.",
559 "type": [
560 "number",
561 "string"
562 ]
563 }
564 },
565 "required": [
566 "port"
567 ],
568 "type": "object"
569 },
570 "listen": {
571 "label": "Attach by listening for incoming socket connection from debugpy",
572 "properties": {
573 "host": {
574 "default": "127.0.0.1",
575 "description": "Hostname or IP address of the interface to listen on.",
576 "type": "string"
577 },
578 "port": {
579 "description": "Port to listen on.",
580 "type": [
581 "number",
582 "string"
583 ]
584 }
585 },
586 "required": [
587 "port"
588 ],
589 "type": "object"
590 },
591 "processId": {
592 "anyOf": [
593 {
594 "default": "${command:pickProcess}",
595 "description": "Use process picker to select a process to attach, or Process ID as integer.",
596 "enum": [
597 "${command:pickProcess}"
598 ]
599 },
600 {
601 "description": "ID of the local process to attach to.",
602 "type": "integer"
603 }
604 ]
605 }
606 }
607 }
608 },
609 {
610 "if": {
611 "properties": {
612 "request": {
613 "enum": ["launch"]
614 }
615 }
616 },
617 "then": {
618 "properties": {
619 "args": {
620 "default": [],
621 "description": "Command line arguments passed to the program. For string type arguments, it will pass through the shell as is, and therefore all shell variable expansions will apply. But for the array type, the values will be shell-escaped.",
622 "items": {
623 "type": "string"
624 },
625 "anyOf": [
626 {
627 "default": "${command:pickArgs}",
628 "enum": [
629 "${command:pickArgs}"
630 ]
631 },
632 {
633 "type": [
634 "array",
635 "string"
636 ]
637 }
638 ]
639 },
640 "console": {
641 "default": "integratedTerminal",
642 "description": "Where to launch the debug target: internal console, integrated terminal, or external terminal.",
643 "enum": [
644 "externalTerminal",
645 "integratedTerminal",
646 "internalConsole"
647 ]
648 },
649 "cwd": {
650 "default": "${ZED_WORKTREE_ROOT}",
651 "description": "Absolute path to the working directory of the program being debugged. Default is the root directory of the file (leave empty).",
652 "type": "string"
653 },
654 "autoStartBrowser": {
655 "default": false,
656 "description": "Open external browser to launch the application",
657 "type": "boolean"
658 },
659 "env": {
660 "additionalProperties": {
661 "type": "string"
662 },
663 "default": {},
664 "description": "Environment variables defined as a key value pair. Property ends up being the Environment Variable and the value of the property ends up being the value of the Env Variable.",
665 "type": "object"
666 },
667 "envFile": {
668 "default": "${ZED_WORKTREE_ROOT}/.env",
669 "description": "Absolute path to a file containing environment variable definitions.",
670 "type": "string"
671 },
672 "gevent": {
673 "default": false,
674 "description": "Enable debugging of gevent monkey-patched code.",
675 "type": "boolean"
676 },
677 "module": {
678 "default": "",
679 "description": "Name of the module to be debugged.",
680 "type": "string"
681 },
682 "program": {
683 "default": "${ZED_FILE}",
684 "description": "Absolute path to the program.",
685 "type": "string"
686 },
687 "purpose": {
688 "default": [],
689 "description": "Tells extension to use this configuration for test debugging, or when using debug-in-terminal command.",
690 "items": {
691 "enum": [
692 "debug-test",
693 "debug-in-terminal"
694 ],
695 "enumDescriptions": [
696 "Use this configuration while debugging tests using test view or test debug commands.",
697 "Use this configuration while debugging a file using debug in terminal button in the editor."
698 ]
699 },
700 "type": "array"
701 },
702 "pyramid": {
703 "default": false,
704 "description": "Whether debugging Pyramid applications.",
705 "type": "boolean"
706 },
707 "python": {
708 "default": "${command:python.interpreterPath}",
709 "description": "Absolute path to the Python interpreter executable; overrides workspace configuration if set.",
710 "type": "string"
711 },
712 "pythonArgs": {
713 "default": [],
714 "description": "Command-line arguments passed to the Python interpreter. To pass arguments to the debug target, use \"args\".",
715 "items": {
716 "type": "string"
717 },
718 "type": "array"
719 },
720 "stopOnEntry": {
721 "default": false,
722 "description": "Automatically stop after launch.",
723 "type": "boolean"
724 },
725 "sudo": {
726 "default": false,
727 "description": "Running debug program under elevated permissions (on Unix).",
728 "type": "boolean"
729 },
730 "guiEventLoop": {
731 "default": "matplotlib",
732 "description": "The GUI event loop that's going to run. Possible values: \"matplotlib\", \"wx\", \"qt\", \"none\", or a custom function that'll be imported and run.",
733 "type": "string"
734 }
735 }
736 }
737 }
738 ]
739 })
740 }
741
742 async fn get_binary(
743 &self,
744 delegate: &Arc<dyn DapDelegate>,
745 config: &DebugTaskDefinition,
746 user_installed_path: Option<PathBuf>,
747 user_args: Option<Vec<String>>,
748 user_env: Option<HashMap<String, String>>,
749 cx: &mut AsyncApp,
750 ) -> Result<DebugAdapterBinary> {
751 if let Some(local_path) = &user_installed_path {
752 log::debug!(
753 "Using user-installed debugpy adapter from: {}",
754 local_path.display()
755 );
756 return self
757 .get_installed_binary(
758 delegate,
759 config,
760 Some(local_path.clone()),
761 user_args,
762 user_env,
763 None,
764 )
765 .await;
766 }
767
768 let base_path = config
769 .config
770 .get("cwd")
771 .and_then(|cwd| {
772 RelPath::new(
773 cwd.as_str()
774 .map(Path::new)?
775 .strip_prefix(delegate.worktree_root_path())
776 .ok()?,
777 PathStyle::local(),
778 )
779 .ok()
780 })
781 .unwrap_or_else(|| RelPath::empty().into());
782 let toolchain = delegate
783 .toolchain_store()
784 .active_toolchain(
785 delegate.worktree_id(),
786 base_path.into_arc(),
787 language::LanguageName::new(Self::LANGUAGE_NAME),
788 cx,
789 )
790 .await;
791
792 self.fetch_debugpy_whl(toolchain.clone(), delegate)
793 .await
794 .map_err(|e| anyhow::anyhow!("{e}"))?;
795 if let Some(toolchain) = &toolchain {
796 return self
797 .get_installed_binary(
798 delegate,
799 config,
800 None,
801 user_args,
802 user_env,
803 Some(toolchain.path.to_string()),
804 )
805 .await;
806 }
807
808 self.get_installed_binary(delegate, config, None, user_args, user_env, None)
809 .await
810 }
811
812 fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option<String> {
813 let label = args
814 .configuration
815 .get("name")?
816 .as_str()
817 .filter(|label| !label.is_empty())?;
818 Some(label.to_owned())
819 }
820}
821
822#[cfg(test)]
823mod tests {
824 use util::path;
825
826 use super::*;
827 use std::{net::Ipv4Addr, path::PathBuf};
828
829 #[gpui::test]
830 async fn test_debugpy_install_path_cases() {
831 let host = Ipv4Addr::new(127, 0, 0, 1);
832 let port = 5678;
833
834 // Case 1: User-defined debugpy path (highest precedence)
835 let user_path = PathBuf::from("/custom/path/to/debugpy/src/debugpy/adapter");
836 let user_args =
837 PythonDebugAdapter::generate_debugpy_arguments(&host, port, Some(&user_path), None)
838 .await
839 .unwrap();
840
841 // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
842 let venv_args = PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, None)
843 .await
844 .unwrap();
845
846 assert_eq!(user_args[0], "/custom/path/to/debugpy/src/debugpy/adapter");
847 assert_eq!(user_args[1], "--host=127.0.0.1");
848 assert_eq!(user_args[2], "--port=5678");
849
850 let expected_suffix = path!("debug_adapters/Debugpy/debugpy/adapter");
851 assert!(venv_args[0].ends_with(expected_suffix));
852 assert_eq!(venv_args[1], "--host=127.0.0.1");
853 assert_eq!(venv_args[2], "--port=5678");
854
855 // The same cases, with arguments overridden by the user
856 let user_args = PythonDebugAdapter::generate_debugpy_arguments(
857 &host,
858 port,
859 Some(&user_path),
860 Some(vec!["foo".into()]),
861 )
862 .await
863 .unwrap();
864 let venv_args = PythonDebugAdapter::generate_debugpy_arguments(
865 &host,
866 port,
867 None,
868 Some(vec!["foo".into()]),
869 )
870 .await
871 .unwrap();
872
873 assert!(user_args[0].ends_with("src/debugpy/adapter"));
874 assert_eq!(user_args[1], "foo");
875
876 assert!(venv_args[0].ends_with(expected_suffix));
877 assert_eq!(venv_args[1], "foo");
878
879 // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
880 }
881}