python.rs

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