debugpy: Fetch a wheel into Zed's work dir and use that with users venv (#35640)

Piotr Osiewicz created

Another stab at #35388
cc @Sansui233

Closes #35388

Release Notes:

- debugger: Fixed Python debug sessions failing to launch due to a
missing debugpy installation.

Change summary

crates/dap_adapters/src/python.rs | 323 +++++++++++++++++---------------
1 file changed, 171 insertions(+), 152 deletions(-)

Detailed changes

crates/dap_adapters/src/python.rs 🔗

@@ -1,38 +1,36 @@
 use crate::*;
 use anyhow::Context as _;
 use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
+use fs::RemoveOptions;
+use futures::{StreamExt, TryStreamExt};
+use gpui::http_client::AsyncBody;
 use gpui::{AsyncApp, SharedString};
 use json_dotpath::DotPaths;
 use language::LanguageName;
 use paths::debug_adapters_dir;
 use serde_json::Value;
+use smol::fs::File;
+use smol::io::AsyncReadExt;
 use smol::lock::OnceCell;
+use std::ffi::OsString;
 use std::net::Ipv4Addr;
+use std::str::FromStr;
 use std::{
     collections::HashMap,
     ffi::OsStr,
     path::{Path, PathBuf},
 };
+use util::{ResultExt, maybe};
 
 #[derive(Default)]
 pub(crate) struct PythonDebugAdapter {
-    python_venv_base: OnceCell<Result<Arc<Path>, String>>,
+    debugpy_whl_base_path: OnceCell<Result<Arc<Path>, String>>,
 }
 
 impl PythonDebugAdapter {
     const ADAPTER_NAME: &'static str = "Debugpy";
     const DEBUG_ADAPTER_NAME: DebugAdapterName =
         DebugAdapterName(SharedString::new_static(Self::ADAPTER_NAME));
-    const PYTHON_ADAPTER_IN_VENV: &'static str = if cfg!(target_os = "windows") {
-        "Scripts/python3"
-    } else {
-        "bin/python3"
-    };
-    const ADAPTER_PATH: &'static str = if cfg!(target_os = "windows") {
-        "debugpy-venv/Scripts/debugpy-adapter"
-    } else {
-        "debugpy-venv/bin/debugpy-adapter"
-    };
 
     const LANGUAGE_NAME: &'static str = "Python";
 
@@ -41,7 +39,6 @@ impl PythonDebugAdapter {
         port: u16,
         user_installed_path: Option<&Path>,
         user_args: Option<Vec<String>>,
-        installed_in_venv: bool,
     ) -> Result<Vec<String>> {
         let mut args = if let Some(user_installed_path) = user_installed_path {
             log::debug!(
@@ -49,13 +46,11 @@ impl PythonDebugAdapter {
                 user_installed_path.display()
             );
             vec![user_installed_path.to_string_lossy().to_string()]
-        } else if installed_in_venv {
-            log::debug!("Using venv-installed debugpy");
-            vec!["-m".to_string(), "debugpy.adapter".to_string()]
         } else {
             let adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
             let path = adapter_path
-                .join(Self::ADAPTER_PATH)
+                .join("debugpy")
+                .join("adapter")
                 .to_string_lossy()
                 .into_owned();
             log::debug!("Using pip debugpy adapter from: {path}");
@@ -96,73 +91,143 @@ impl PythonDebugAdapter {
         })
     }
 
-    async fn ensure_venv(delegate: &dyn DapDelegate) -> Result<Arc<Path>> {
-        let python_path = Self::find_base_python(delegate)
+    async fn fetch_wheel(delegate: &Arc<dyn DapDelegate>) -> Result<Arc<Path>, String> {
+        let system_python = Self::system_python_name(delegate)
             .await
-            .context("Could not find Python installation for DebugPy")?;
-        let work_dir = debug_adapters_dir().join(Self::ADAPTER_NAME);
-        if !work_dir.exists() {
-            std::fs::create_dir_all(&work_dir)?;
-        }
-        let mut path = work_dir.clone();
-        path.push("debugpy-venv");
-        if !path.exists() {
-            util::command::new_smol_command(python_path)
-                .arg("-m")
-                .arg("venv")
-                .arg("debugpy-venv")
-                .current_dir(work_dir)
-                .spawn()?
-                .output()
-                .await?;
+            .ok_or_else(|| String::from("Could not find a Python installation"))?;
+        let command: &OsStr = system_python.as_ref();
+        let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME).join("wheels");
+        std::fs::create_dir_all(&download_dir).map_err(|e| e.to_string())?;
+        let installation_succeeded = util::command::new_smol_command(command)
+            .args([
+                "-m",
+                "pip",
+                "download",
+                "debugpy",
+                "--only-binary=:all:",
+                "-d",
+                download_dir.to_string_lossy().as_ref(),
+            ])
+            .output()
+            .await
+            .map_err(|e| format!("{e}"))?
+            .status
+            .success();
+        if !installation_succeeded {
+            return Err("debugpy installation failed".into());
         }
 
-        Ok(path.into())
+        let wheel_path = std::fs::read_dir(&download_dir)
+            .map_err(|e| e.to_string())?
+            .find_map(|entry| {
+                entry.ok().filter(|e| {
+                    e.file_type().is_ok_and(|typ| typ.is_file())
+                        && Path::new(&e.file_name()).extension() == Some("whl".as_ref())
+                })
+            })
+            .ok_or_else(|| String::from("Did not find a .whl in {download_dir}"))?;
+
+        util::archive::extract_zip(
+            &debug_adapters_dir().join(Self::ADAPTER_NAME),
+            File::open(&wheel_path.path())
+                .await
+                .map_err(|e| e.to_string())?,
+        )
+        .await
+        .map_err(|e| e.to_string())?;
+
+        Ok(Arc::from(wheel_path.path()))
     }
 
-    // Find "baseline", user python version from which we'll create our own venv.
-    async fn find_base_python(delegate: &dyn DapDelegate) -> Option<PathBuf> {
-        for path in ["python3", "python"] {
-            if let Some(path) = delegate.which(path.as_ref()).await {
-                return Some(path);
+    async fn maybe_fetch_new_wheel(delegate: &Arc<dyn DapDelegate>) {
+        let latest_release = delegate
+            .http_client()
+            .get(
+                "https://pypi.org/pypi/debugpy/json",
+                AsyncBody::empty(),
+                false,
+            )
+            .await
+            .log_err();
+        maybe!(async move {
+            let response = latest_release.filter(|response| response.status().is_success())?;
+
+            let mut output = String::new();
+            response
+                .into_body()
+                .read_to_string(&mut output)
+                .await
+                .ok()?;
+            let as_json = serde_json::Value::from_str(&output).ok()?;
+            let latest_version = as_json.get("info").and_then(|info| {
+                info.get("version")
+                    .and_then(|version| version.as_str())
+                    .map(ToOwned::to_owned)
+            })?;
+            let dist_info_dirname: OsString = format!("debugpy-{latest_version}.dist-info").into();
+            let is_up_to_date = delegate
+                .fs()
+                .read_dir(&debug_adapters_dir().join(Self::ADAPTER_NAME))
+                .await
+                .ok()?
+                .into_stream()
+                .any(async |entry| {
+                    entry.is_ok_and(|e| e.file_name().is_some_and(|name| name == dist_info_dirname))
+                })
+                .await;
+
+            if !is_up_to_date {
+                delegate
+                    .fs()
+                    .remove_dir(
+                        &debug_adapters_dir().join(Self::ADAPTER_NAME),
+                        RemoveOptions {
+                            recursive: true,
+                            ignore_if_not_exists: true,
+                        },
+                    )
+                    .await
+                    .ok()?;
+                Self::fetch_wheel(delegate).await.ok()?;
             }
-        }
-        None
+            Some(())
+        })
+        .await;
     }
-    const BINARY_DIR: &str = if cfg!(target_os = "windows") {
-        "Scripts"
-    } else {
-        "bin"
-    };
-    async fn base_venv(&self, delegate: &dyn DapDelegate) -> Result<Arc<Path>, String> {
-        self.python_venv_base
-            .get_or_init(move || async move {
-                let venv_base = Self::ensure_venv(delegate)
-                    .await
-                    .map_err(|e| format!("{e}"))?;
-                Self::install_debugpy_into_venv(&venv_base).await?;
-                Ok(venv_base)
+
+    async fn fetch_debugpy_whl(
+        &self,
+        delegate: &Arc<dyn DapDelegate>,
+    ) -> Result<Arc<Path>, String> {
+        self.debugpy_whl_base_path
+            .get_or_init(|| async move {
+                Self::maybe_fetch_new_wheel(delegate).await;
+                Ok(Arc::from(
+                    debug_adapters_dir()
+                        .join(Self::ADAPTER_NAME)
+                        .join("debugpy")
+                        .join("adapter")
+                        .as_ref(),
+                ))
             })
             .await
             .clone()
     }
 
-    async fn install_debugpy_into_venv(venv_path: &Path) -> Result<(), String> {
-        let pip_path = venv_path.join(Self::BINARY_DIR).join("pip3");
-        let installation_succeeded = util::command::new_smol_command(pip_path.as_path())
-            .arg("install")
-            .arg("debugpy")
-            .arg("-U")
-            .output()
-            .await
-            .map_err(|e| format!("{e}"))?
-            .status
-            .success();
-        if !installation_succeeded {
-            return Err("debugpy installation failed".into());
-        }
+    async fn system_python_name(delegate: &Arc<dyn DapDelegate>) -> Option<String> {
+        const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
+        let mut name = None;
 
-        Ok(())
+        for cmd in BINARY_NAMES {
+            name = delegate
+                .which(OsStr::new(cmd))
+                .await
+                .map(|path| path.to_string_lossy().to_string());
+            if name.is_some() {
+                break;
+            }
+        }
+        name
     }
 
     async fn get_installed_binary(
@@ -172,27 +237,14 @@ impl PythonDebugAdapter {
         user_installed_path: Option<PathBuf>,
         user_args: Option<Vec<String>>,
         python_from_toolchain: Option<String>,
-        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 python_path = if let Some(toolchain) = python_from_toolchain {
             Some(toolchain)
         } else {
-            let mut name = None;
-
-            for cmd in BINARY_NAMES {
-                name = delegate
-                    .which(OsStr::new(cmd))
-                    .await
-                    .map(|path| path.to_string_lossy().to_string());
-                if name.is_some() {
-                    break;
-                }
-            }
-            name
+            Self::system_python_name(delegate).await
         };
 
         let python_command = python_path.context("failed to find binary path for Python")?;
@@ -203,7 +255,6 @@ impl PythonDebugAdapter {
             port,
             user_installed_path.as_deref(),
             user_args,
-            installed_in_venv,
         )
         .await?;
 
@@ -625,14 +676,7 @@ impl DebugAdapter for PythonDebugAdapter {
                 local_path.display()
             );
             return self
-                .get_installed_binary(
-                    delegate,
-                    &config,
-                    Some(local_path.clone()),
-                    user_args,
-                    None,
-                    false,
-                )
+                .get_installed_binary(delegate, &config, Some(local_path.clone()), user_args, None)
                 .await;
         }
 
@@ -657,46 +701,28 @@ impl DebugAdapter for PythonDebugAdapter {
             )
             .await;
 
+        let debugpy_path = self
+            .fetch_debugpy_whl(delegate)
+            .await
+            .map_err(|e| anyhow::anyhow!("{e}"))?;
         if let Some(toolchain) = &toolchain {
-            if let Some(path) = Path::new(&toolchain.path.to_string()).parent() {
-                if let Some(parent) = path.parent() {
-                    Self::install_debugpy_into_venv(parent).await.ok();
-                }
-
-                let debugpy_path = path.join("debugpy");
-                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,
-                            None,
-                            user_args,
-                            Some(toolchain.path.to_string()),
-                            true,
-                        )
-                        .await;
-                }
-            }
+            log::debug!(
+                "Found debugpy in toolchain environment: {}",
+                debugpy_path.display()
+            );
+            return self
+                .get_installed_binary(
+                    delegate,
+                    &config,
+                    None,
+                    user_args,
+                    Some(toolchain.path.to_string()),
+                )
+                .await;
         }
-        let toolchain = self
-            .base_venv(&**delegate)
-            .await
-            .map_err(|e| anyhow::anyhow!(e))?
-            .join(Self::PYTHON_ADAPTER_IN_VENV);
 
-        self.get_installed_binary(
-            delegate,
-            &config,
-            None,
-            user_args,
-            Some(toolchain.to_string_lossy().into_owned()),
-            false,
-        )
-        .await
+        self.get_installed_binary(delegate, &config, None, user_args, None)
+            .await
     }
 
     fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option<String> {
@@ -711,6 +737,8 @@ impl DebugAdapter for PythonDebugAdapter {
 
 #[cfg(test)]
 mod tests {
+    use util::path;
+
     use super::*;
     use std::{net::Ipv4Addr, path::PathBuf};
 
@@ -721,30 +749,24 @@ mod tests {
 
         // Case 1: User-defined debugpy path (highest precedence)
         let user_path = PathBuf::from("/custom/path/to/debugpy/src/debugpy/adapter");
-        let user_args = PythonDebugAdapter::generate_debugpy_arguments(
-            &host,
-            port,
-            Some(&user_path),
-            None,
-            false,
-        )
-        .await
-        .unwrap();
-
-        // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
-        let venv_args =
-            PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, None, true)
+        let user_args =
+            PythonDebugAdapter::generate_debugpy_arguments(&host, port, Some(&user_path), None)
                 .await
                 .unwrap();
 
+        // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
+        let venv_args = PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, None)
+            .await
+            .unwrap();
+
         assert_eq!(user_args[0], "/custom/path/to/debugpy/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");
+        let expected_suffix = path!("debug_adapters/Debugpy/debugpy/adapter");
+        assert!(venv_args[0].ends_with(expected_suffix));
+        assert_eq!(venv_args[1], "--host=127.0.0.1");
+        assert_eq!(venv_args[2], "--port=5678");
 
         // The same cases, with arguments overridden by the user
         let user_args = PythonDebugAdapter::generate_debugpy_arguments(
@@ -752,7 +774,6 @@ mod tests {
             port,
             Some(&user_path),
             Some(vec!["foo".into()]),
-            false,
         )
         .await
         .unwrap();
@@ -761,7 +782,6 @@ mod tests {
             port,
             None,
             Some(vec!["foo".into()]),
-            true,
         )
         .await
         .unwrap();
@@ -769,9 +789,8 @@ mod tests {
         assert!(user_args[0].ends_with("src/debugpy/adapter"));
         assert_eq!(user_args[1], "foo");
 
-        assert_eq!(venv_args[0], "-m");
-        assert_eq!(venv_args[1], "debugpy.adapter");
-        assert_eq!(venv_args[2], "foo");
+        assert!(venv_args[0].ends_with(expected_suffix));
+        assert_eq!(venv_args[1], "foo");
 
         // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
     }