debugger beta: Fix install detection for Debugpy in venv (#31339)

Raphael LΓΌthy created

Based on my report on discord when chatting with Anthony and Remco:
https://discord.com/channels/869392257814519848/1375129714645012530

Root Cause: Zed was incorrectly trying to execute a directory path
instead of properly invoking the debugpy module when debugpy was
installed via package managers (pip, conda, etc.) rather than downloaded
from GitHub releases.

Solution:

- Automatic Detection: Zed now automatically detects whether debugpy is
installed via pip/conda or downloaded from GitHub
- Correct Invocation: For pip-installed debugpy, Zed now uses python -m
debugpy.adapter instead of trying to execute file paths
- Added a `installed_in_venv` flag to differentiate the setup properly
- Backward Compatibility: GitHub-downloaded debugpy releases continue to
work as before
- Enhanced Logging: Added logging to show which debugpy installation
method is being used (I had to verify it somehow)

I verified with the following setups (can be confirmed with the debug
logs):
- `conda` with installed debugpy, went to installed instance
- `uv` with installed debugpy, went to installed instance
- `uv` without installed debugpy, went to github releases
- Homebrew global python install, went to github releases

Release Notes:

- Fix issue where debugpy from different environments won't load as
intended

Change summary

Cargo.lock                        |   2 
crates/dap_adapters/Cargo.toml    |   2 
crates/dap_adapters/src/python.rs | 161 +++++++++++++++++++++++++++-----
3 files changed, 138 insertions(+), 27 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -4058,10 +4058,10 @@ dependencies = [
  "gpui",
  "json_dotpath",
  "language",
+ "log",
  "paths",
  "serde",
  "serde_json",
- "smol",
  "task",
  "util",
  "workspace-hack",

crates/dap_adapters/Cargo.toml πŸ”—

@@ -28,10 +28,10 @@ futures.workspace = true
 gpui.workspace = true
 json_dotpath.workspace = true
 language.workspace = true
+log.workspace = true
 paths.workspace = true
 serde.workspace = true
 serde_json.workspace = true
-smol.workspace = true
 task.workspace = true
 util.workspace = true
 workspace-hack.workspace = true

crates/dap_adapters/src/python.rs πŸ”—

@@ -8,6 +8,7 @@ use gpui::{AsyncApp, SharedString};
 use json_dotpath::DotPaths;
 use language::{LanguageName, Toolchain};
 use serde_json::Value;
+use std::net::Ipv4Addr;
 use std::{
     collections::HashMap,
     ffi::OsStr,
@@ -27,6 +28,60 @@ impl PythonDebugAdapter {
     const ADAPTER_PATH: &'static str = "src/debugpy/adapter";
     const LANGUAGE_NAME: &'static str = "Python";
 
+    async fn generate_debugpy_arguments(
+        &self,
+        host: &Ipv4Addr,
+        port: u16,
+        user_installed_path: Option<&Path>,
+        installed_in_venv: bool,
+    ) -> Result<Vec<String>> {
+        if let Some(user_installed_path) = user_installed_path {
+            log::debug!(
+                "Using user-installed debugpy adapter from: {}",
+                user_installed_path.display()
+            );
+            Ok(vec![
+                user_installed_path
+                    .join(Self::ADAPTER_PATH)
+                    .to_string_lossy()
+                    .to_string(),
+                format!("--host={}", host),
+                format!("--port={}", port),
+            ])
+        } else if installed_in_venv {
+            log::debug!("Using venv-installed debugpy");
+            Ok(vec![
+                "-m".to_string(),
+                "debugpy.adapter".to_string(),
+                format!("--host={}", host),
+                format!("--port={}", port),
+            ])
+        } else {
+            let adapter_path = paths::debug_adapters_dir().join(self.name().as_ref());
+            let file_name_prefix = format!("{}_", Self::ADAPTER_NAME);
+
+            let debugpy_dir =
+                util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
+                    file_name.starts_with(&file_name_prefix)
+                })
+                .await
+                .context("Debugpy directory not found")?;
+
+            log::debug!(
+                "Using GitHub-downloaded debugpy adapter from: {}",
+                debugpy_dir.display()
+            );
+            Ok(vec![
+                debugpy_dir
+                    .join(Self::ADAPTER_PATH)
+                    .to_string_lossy()
+                    .to_string(),
+                format!("--host={}", host),
+                format!("--port={}", port),
+            ])
+        }
+    }
+
     fn request_args(
         &self,
         task_definition: &DebugTaskDefinition,
@@ -93,24 +148,12 @@ impl PythonDebugAdapter {
         config: &DebugTaskDefinition,
         user_installed_path: Option<PathBuf>,
         toolchain: Option<Toolchain>,
+        installed_in_venv: bool,
     ) -> Result<DebugAdapterBinary> {
         const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
         let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
         let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
 
-        let debugpy_dir = if let Some(user_installed_path) = user_installed_path {
-            user_installed_path
-        } else {
-            let adapter_path = paths::debug_adapters_dir().join(self.name().as_ref());
-            let file_name_prefix = format!("{}_", Self::ADAPTER_NAME);
-
-            util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
-                file_name.starts_with(&file_name_prefix)
-            })
-            .await
-            .context("Debugpy directory not found")?
-        };
-
         let python_path = if let Some(toolchain) = toolchain {
             Some(toolchain.path.to_string())
         } else {
@@ -128,16 +171,27 @@ impl PythonDebugAdapter {
             name
         };
 
+        let python_command = python_path.context("failed to find binary path for Python")?;
+        log::debug!("Using Python executable: {}", python_command);
+
+        let arguments = self
+            .generate_debugpy_arguments(
+                &host,
+                port,
+                user_installed_path.as_deref(),
+                installed_in_venv,
+            )
+            .await?;
+
+        log::debug!(
+            "Starting debugpy adapter with command: {} {}",
+            python_command,
+            arguments.join(" ")
+        );
+
         Ok(DebugAdapterBinary {
-            command: python_path.context("failed to find binary path for Python")?,
-            arguments: vec![
-                debugpy_dir
-                    .join(Self::ADAPTER_PATH)
-                    .to_string_lossy()
-                    .to_string(),
-                format!("--port={}", port),
-                format!("--host={}", host),
-            ],
+            command: python_command,
+            arguments,
             connection: Some(adapters::TcpArguments {
                 host,
                 port,
@@ -558,6 +612,16 @@ impl DebugAdapter for PythonDebugAdapter {
         user_installed_path: Option<PathBuf>,
         cx: &mut AsyncApp,
     ) -> Result<DebugAdapterBinary> {
+        if let Some(local_path) = &user_installed_path {
+            log::debug!(
+                "Using user-installed debugpy adapter from: {}",
+                local_path.display()
+            );
+            return self
+                .get_installed_binary(delegate, &config, Some(local_path.clone()), None, false)
+                .await;
+        }
+
         let toolchain = delegate
             .toolchain_store()
             .active_toolchain(
@@ -571,13 +635,18 @@ impl DebugAdapter for PythonDebugAdapter {
         if let Some(toolchain) = &toolchain {
             if let Some(path) = Path::new(&toolchain.path.to_string()).parent() {
                 let debugpy_path = path.join("debugpy");
-                if smol::fs::metadata(&debugpy_path).await.is_ok() {
+                if delegate.fs().is_file(&debugpy_path).await {
+                    log::debug!(
+                        "Found debugpy in toolchain environment: {}",
+                        debugpy_path.display()
+                    );
                     return self
                         .get_installed_binary(
                             delegate,
                             &config,
-                            Some(debugpy_path.to_path_buf()),
+                            None,
                             Some(toolchain.clone()),
+                            true,
                         )
                         .await;
                 }
@@ -591,7 +660,49 @@ impl DebugAdapter for PythonDebugAdapter {
             }
         }
 
-        self.get_installed_binary(delegate, &config, user_installed_path, toolchain)
+        self.get_installed_binary(delegate, &config, None, None, false)
+            .await
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::{net::Ipv4Addr, path::PathBuf};
+
+    #[gpui::test]
+    async fn test_debugpy_install_path_cases() {
+        let adapter = PythonDebugAdapter::default();
+        let host = Ipv4Addr::new(127, 0, 0, 1);
+        let port = 5678;
+
+        // Case 1: User-defined debugpy path (highest precedence)
+        let user_path = PathBuf::from("/custom/path/to/debugpy");
+        let user_args = adapter
+            .generate_debugpy_arguments(&host, port, Some(&user_path), false)
             .await
+            .unwrap();
+
+        // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
+        let venv_args = adapter
+            .generate_debugpy_arguments(&host, port, None, true)
+            .await
+            .unwrap();
+
+        assert!(user_args[0].ends_with("src/debugpy/adapter"));
+        assert_eq!(user_args[1], "--host=127.0.0.1");
+        assert_eq!(user_args[2], "--port=5678");
+
+        assert_eq!(venv_args[0], "-m");
+        assert_eq!(venv_args[1], "debugpy.adapter");
+        assert_eq!(venv_args[2], "--host=127.0.0.1");
+        assert_eq!(venv_args[3], "--port=5678");
+
+        // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
+    }
+
+    #[test]
+    fn test_adapter_path_constant() {
+        assert_eq!(PythonDebugAdapter::ADAPTER_PATH, "src/debugpy/adapter");
     }
 }