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