python.rs

  1use crate::*;
  2use anyhow::{Context as _, bail};
  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, Toolchain};
 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::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        python_from_toolchain: Option<String>,
316    ) -> Result<DebugAdapterBinary> {
317        let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
318        let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
319
320        let python_path = if let Some(toolchain) = python_from_toolchain {
321            Some(toolchain)
322        } else {
323            Self::system_python_name(delegate).await
324        };
325
326        let python_command = python_path.context("failed to find binary path for Python")?;
327        log::debug!("Using Python executable: {}", python_command);
328
329        let arguments = Self::generate_debugpy_arguments(
330            &host,
331            port,
332            user_installed_path.as_deref(),
333            user_args,
334        )
335        .await?;
336
337        log::debug!(
338            "Starting debugpy adapter with command: {} {}",
339            python_command,
340            arguments.join(" ")
341        );
342
343        Ok(DebugAdapterBinary {
344            command: Some(python_command),
345            arguments,
346            connection: Some(adapters::TcpArguments {
347                host,
348                port,
349                timeout,
350            }),
351            cwd: Some(delegate.worktree_root_path().to_path_buf()),
352            envs: HashMap::default(),
353            request_args: self.request_args(delegate, config).await?,
354        })
355    }
356}
357
358#[async_trait(?Send)]
359impl DebugAdapter for PythonDebugAdapter {
360    fn name(&self) -> DebugAdapterName {
361        Self::DEBUG_ADAPTER_NAME
362    }
363
364    fn adapter_language_name(&self) -> Option<LanguageName> {
365        Some(SharedString::new_static("Python").into())
366    }
367
368    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
369        let mut args = json!({
370            "request": match zed_scenario.request {
371                DebugRequest::Launch(_) => "launch",
372                DebugRequest::Attach(_) => "attach",
373            },
374            "subProcess": true,
375            "redirectOutput": true,
376        });
377
378        let map = args.as_object_mut().unwrap();
379        match &zed_scenario.request {
380            DebugRequest::Attach(attach) => {
381                map.insert("processId".into(), attach.process_id.into());
382            }
383            DebugRequest::Launch(launch) => {
384                map.insert("program".into(), launch.program.clone().into());
385                map.insert("args".into(), launch.args.clone().into());
386                if !launch.env.is_empty() {
387                    map.insert("env".into(), launch.env_json());
388                }
389
390                if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
391                    map.insert("stopOnEntry".into(), stop_on_entry.into());
392                }
393                if let Some(cwd) = launch.cwd.as_ref() {
394                    map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
395                }
396            }
397        }
398
399        Ok(DebugScenario {
400            adapter: zed_scenario.adapter,
401            label: zed_scenario.label,
402            config: args,
403            build: None,
404            tcp_connection: None,
405        })
406    }
407
408    fn dap_schema(&self) -> serde_json::Value {
409        json!({
410            "properties": {
411                "request": {
412                    "type": "string",
413                    "enum": ["attach", "launch"],
414                    "description": "Debug adapter request type"
415                },
416                "autoReload": {
417                    "default": {},
418                    "description": "Configures automatic reload of code on edit.",
419                    "properties": {
420                        "enable": {
421                            "default": false,
422                            "description": "Automatically reload code on edit.",
423                            "type": "boolean"
424                        },
425                        "exclude": {
426                            "default": [
427                                "**/.git/**",
428                                "**/.metadata/**",
429                                "**/__pycache__/**",
430                                "**/node_modules/**",
431                                "**/site-packages/**"
432                            ],
433                            "description": "Glob patterns of paths to exclude from auto reload.",
434                            "items": {
435                                "type": "string"
436                            },
437                            "type": "array"
438                        },
439                        "include": {
440                            "default": [
441                                "**/*.py",
442                                "**/*.pyw"
443                            ],
444                            "description": "Glob patterns of paths to include in auto reload.",
445                            "items": {
446                                "type": "string"
447                            },
448                            "type": "array"
449                        }
450                    },
451                    "type": "object"
452                },
453                "debugAdapterPath": {
454                    "description": "Path (fully qualified) to the python debug adapter executable.",
455                    "type": "string"
456                },
457                "django": {
458                    "default": false,
459                    "description": "Django debugging.",
460                    "type": "boolean"
461                },
462                "jinja": {
463                    "default": null,
464                    "description": "Jinja template debugging (e.g. Flask).",
465                    "enum": [
466                        false,
467                        null,
468                        true
469                    ]
470                },
471                "justMyCode": {
472                    "default": true,
473                    "description": "If true, show and debug only user-written code. If false, show and debug all code, including library calls.",
474                    "type": "boolean"
475                },
476                "logToFile": {
477                    "default": false,
478                    "description": "Enable logging of debugger events to a log file. This file can be found in the debugpy extension install folder.",
479                    "type": "boolean"
480                },
481                "pathMappings": {
482                    "default": [],
483                    "items": {
484                        "label": "Path mapping",
485                        "properties": {
486                            "localRoot": {
487                                "default": "${ZED_WORKTREE_ROOT}",
488                                "label": "Local source root.",
489                                "type": "string"
490                            },
491                            "remoteRoot": {
492                                "default": "",
493                                "label": "Remote source root.",
494                                "type": "string"
495                            }
496                        },
497                        "required": [
498                            "localRoot",
499                            "remoteRoot"
500                        ],
501                        "type": "object"
502                    },
503                    "label": "Path mappings.",
504                    "type": "array"
505                },
506                "redirectOutput": {
507                    "default": true,
508                    "description": "Redirect output.",
509                    "type": "boolean"
510                },
511                "showReturnValue": {
512                    "default": true,
513                    "description": "Show return value of functions when stepping.",
514                    "type": "boolean"
515                },
516                "subProcess": {
517                    "default": false,
518                    "description": "Whether to enable Sub Process debugging",
519                    "type": "boolean"
520                },
521                "consoleName": {
522                    "default": "Python Debug Console",
523                    "description": "Display name of the debug console or terminal",
524                    "type": "string"
525                },
526                "clientOS": {
527                    "default": null,
528                    "description": "OS that VS code is using.",
529                    "enum": [
530                        "windows",
531                        null,
532                        "unix"
533                    ]
534                }
535            },
536            "required": ["request"],
537            "allOf": [
538                {
539                    "if": {
540                        "properties": {
541                            "request": {
542                                "enum": ["attach"]
543                            }
544                        }
545                    },
546                    "then": {
547                        "properties": {
548                            "connect": {
549                                "label": "Attach by connecting to debugpy over a socket.",
550                                "properties": {
551                                    "host": {
552                                        "default": "127.0.0.1",
553                                        "description": "Hostname or IP address to connect to.",
554                                        "type": "string"
555                                    },
556                                    "port": {
557                                        "description": "Port to connect to.",
558                                        "type": [
559                                            "number",
560                                            "string"
561                                        ]
562                                    }
563                                },
564                                "required": [
565                                    "port"
566                                ],
567                                "type": "object"
568                            },
569                            "listen": {
570                                "label": "Attach by listening for incoming socket connection from debugpy",
571                                "properties": {
572                                    "host": {
573                                        "default": "127.0.0.1",
574                                        "description": "Hostname or IP address of the interface to listen on.",
575                                        "type": "string"
576                                    },
577                                    "port": {
578                                        "description": "Port to listen on.",
579                                        "type": [
580                                            "number",
581                                            "string"
582                                        ]
583                                    }
584                                },
585                                "required": [
586                                    "port"
587                                ],
588                                "type": "object"
589                            },
590                            "processId": {
591                                "anyOf": [
592                                    {
593                                        "default": "${command:pickProcess}",
594                                        "description": "Use process picker to select a process to attach, or Process ID as integer.",
595                                        "enum": [
596                                            "${command:pickProcess}"
597                                        ]
598                                    },
599                                    {
600                                        "description": "ID of the local process to attach to.",
601                                        "type": "integer"
602                                    }
603                                ]
604                            }
605                        }
606                    }
607                },
608                {
609                    "if": {
610                        "properties": {
611                            "request": {
612                                "enum": ["launch"]
613                            }
614                        }
615                    },
616                    "then": {
617                        "properties": {
618                            "args": {
619                                "default": [],
620                                "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.",
621                                "items": {
622                                    "type": "string"
623                                },
624                                "anyOf": [
625                                    {
626                                        "default": "${command:pickArgs}",
627                                        "enum": [
628                                            "${command:pickArgs}"
629                                        ]
630                                    },
631                                    {
632                                        "type": [
633                                            "array",
634                                            "string"
635                                        ]
636                                    }
637                                ]
638                            },
639                            "console": {
640                                "default": "integratedTerminal",
641                                "description": "Where to launch the debug target: internal console, integrated terminal, or external terminal.",
642                                "enum": [
643                                    "externalTerminal",
644                                    "integratedTerminal",
645                                    "internalConsole"
646                                ]
647                            },
648                            "cwd": {
649                                "default": "${ZED_WORKTREE_ROOT}",
650                                "description": "Absolute path to the working directory of the program being debugged. Default is the root directory of the file (leave empty).",
651                                "type": "string"
652                            },
653                            "autoStartBrowser": {
654                                "default": false,
655                                "description": "Open external browser to launch the application",
656                                "type": "boolean"
657                            },
658                            "env": {
659                                "additionalProperties": {
660                                    "type": "string"
661                                },
662                                "default": {},
663                                "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.",
664                                "type": "object"
665                            },
666                            "envFile": {
667                                "default": "${ZED_WORKTREE_ROOT}/.env",
668                                "description": "Absolute path to a file containing environment variable definitions.",
669                                "type": "string"
670                            },
671                            "gevent": {
672                                "default": false,
673                                "description": "Enable debugging of gevent monkey-patched code.",
674                                "type": "boolean"
675                            },
676                            "module": {
677                                "default": "",
678                                "description": "Name of the module to be debugged.",
679                                "type": "string"
680                            },
681                            "program": {
682                                "default": "${ZED_FILE}",
683                                "description": "Absolute path to the program.",
684                                "type": "string"
685                            },
686                            "purpose": {
687                                "default": [],
688                                "description": "Tells extension to use this configuration for test debugging, or when using debug-in-terminal command.",
689                                "items": {
690                                    "enum": [
691                                        "debug-test",
692                                        "debug-in-terminal"
693                                    ],
694                                    "enumDescriptions": [
695                                        "Use this configuration while debugging tests using test view or test debug commands.",
696                                        "Use this configuration while debugging a file using debug in terminal button in the editor."
697                                    ]
698                                },
699                                "type": "array"
700                            },
701                            "pyramid": {
702                                "default": false,
703                                "description": "Whether debugging Pyramid applications.",
704                                "type": "boolean"
705                            },
706                            "python": {
707                                "default": "${command:python.interpreterPath}",
708                                "description": "Absolute path to the Python interpreter executable; overrides workspace configuration if set.",
709                                "type": "string"
710                            },
711                            "pythonArgs": {
712                                "default": [],
713                                "description": "Command-line arguments passed to the Python interpreter. To pass arguments to the debug target, use \"args\".",
714                                "items": {
715                                    "type": "string"
716                                },
717                                "type": "array"
718                            },
719                            "stopOnEntry": {
720                                "default": false,
721                                "description": "Automatically stop after launch.",
722                                "type": "boolean"
723                            },
724                            "sudo": {
725                                "default": false,
726                                "description": "Running debug program under elevated permissions (on Unix).",
727                                "type": "boolean"
728                            },
729                            "guiEventLoop": {
730                                "default": "matplotlib",
731                                "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.",
732                                "type": "string"
733                            }
734                        }
735                    }
736                }
737            ]
738        })
739    }
740
741    async fn get_binary(
742        &self,
743        delegate: &Arc<dyn DapDelegate>,
744        config: &DebugTaskDefinition,
745        user_installed_path: Option<PathBuf>,
746        user_args: Option<Vec<String>>,
747        cx: &mut AsyncApp,
748    ) -> Result<DebugAdapterBinary> {
749        if let Some(local_path) = &user_installed_path {
750            log::debug!(
751                "Using user-installed debugpy adapter from: {}",
752                local_path.display()
753            );
754            return self
755                .get_installed_binary(delegate, config, Some(local_path.clone()), user_args, None)
756                .await;
757        }
758
759        let base_path = config
760            .config
761            .get("cwd")
762            .and_then(|cwd| {
763                RelPath::new(
764                    cwd.as_str()
765                        .map(Path::new)?
766                        .strip_prefix(delegate.worktree_root_path())
767                        .ok()?,
768                    PathStyle::local(),
769                )
770                .ok()
771            })
772            .unwrap_or_else(|| RelPath::empty().into());
773        let toolchain = delegate
774            .toolchain_store()
775            .active_toolchain(
776                delegate.worktree_id(),
777                base_path.into_arc(),
778                language::LanguageName::new(Self::LANGUAGE_NAME),
779                cx,
780            )
781            .await;
782
783        self.fetch_debugpy_whl(toolchain.clone(), delegate)
784            .await
785            .map_err(|e| anyhow::anyhow!("{e}"))?;
786        if let Some(toolchain) = &toolchain {
787            return self
788                .get_installed_binary(
789                    delegate,
790                    config,
791                    None,
792                    user_args,
793                    Some(toolchain.path.to_string()),
794                )
795                .await;
796        }
797
798        self.get_installed_binary(delegate, config, None, user_args, None)
799            .await
800    }
801
802    fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option<String> {
803        let label = args
804            .configuration
805            .get("name")?
806            .as_str()
807            .filter(|label| !label.is_empty())?;
808        Some(label.to_owned())
809    }
810}
811
812#[cfg(test)]
813mod tests {
814    use util::path;
815
816    use super::*;
817    use std::{net::Ipv4Addr, path::PathBuf};
818
819    #[gpui::test]
820    async fn test_debugpy_install_path_cases() {
821        let host = Ipv4Addr::new(127, 0, 0, 1);
822        let port = 5678;
823
824        // Case 1: User-defined debugpy path (highest precedence)
825        let user_path = PathBuf::from("/custom/path/to/debugpy/src/debugpy/adapter");
826        let user_args =
827            PythonDebugAdapter::generate_debugpy_arguments(&host, port, Some(&user_path), None)
828                .await
829                .unwrap();
830
831        // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
832        let venv_args = PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, None)
833            .await
834            .unwrap();
835
836        assert_eq!(user_args[0], "/custom/path/to/debugpy/src/debugpy/adapter");
837        assert_eq!(user_args[1], "--host=127.0.0.1");
838        assert_eq!(user_args[2], "--port=5678");
839
840        let expected_suffix = path!("debug_adapters/Debugpy/debugpy/adapter");
841        assert!(venv_args[0].ends_with(expected_suffix));
842        assert_eq!(venv_args[1], "--host=127.0.0.1");
843        assert_eq!(venv_args[2], "--port=5678");
844
845        // The same cases, with arguments overridden by the user
846        let user_args = PythonDebugAdapter::generate_debugpy_arguments(
847            &host,
848            port,
849            Some(&user_path),
850            Some(vec!["foo".into()]),
851        )
852        .await
853        .unwrap();
854        let venv_args = PythonDebugAdapter::generate_debugpy_arguments(
855            &host,
856            port,
857            None,
858            Some(vec!["foo".into()]),
859        )
860        .await
861        .unwrap();
862
863        assert!(user_args[0].ends_with("src/debugpy/adapter"));
864        assert_eq!(user_args[1], "foo");
865
866        assert!(venv_args[0].ends_with(expected_suffix));
867        assert_eq!(venv_args[1], "foo");
868
869        // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
870    }
871}