windows: Detect when `python3` is not usable and notify the user (#40070)

Cole Miller created

Fixes #39998

Debugpy and pylsp are installed in a Zed-global venv with pip. We need a
Python interpreter to create this venv when it doesn't exist and one of
these tools needs to be installed, and sometimes we attempt to use
`python3` from `$PATH`. This can cause issues on Windows, where out of
the box `python3` is a sort of shim that opens the Microsoft Store app.

This PR changes the debugpy installation path to create the Zed-global
venv using the Python interpreter from a venv in the project, and only
use python3 from `$PATH` if that fails. That matches how pylsp
installation already works. It also tightens up how we search for a
global Python installation by doing a basic sanity check (`python3 -c
'print(1 + 2)`) before accepting it, which should catch the Windows
shim.

Release Notes:

- windows: improved the behavior of Zed in situations where no global
Python installation exists.

Change summary

crates/dap_adapters/src/python.rs        | 194 ++++++++++++++-----------
crates/debugger_ui/src/debugger_panel.rs |   4 
crates/languages/src/python.rs           |  28 +++
main.py                                  |   0 
4 files changed, 137 insertions(+), 89 deletions(-)

Detailed changes

crates/dap_adapters/src/python.rs 🔗

@@ -1,12 +1,12 @@
 use crate::*;
-use anyhow::Context as _;
+use anyhow::{Context as _, bail};
 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 language::{LanguageName, Toolchain};
 use paths::debug_adapters_dir;
 use serde_json::Value;
 use smol::fs::File;
@@ -20,7 +20,8 @@ use std::{
     ffi::OsStr,
     path::{Path, PathBuf},
 };
-use util::{ResultExt, maybe, paths::PathStyle, rel_path::RelPath};
+use util::command::new_smol_command;
+use util::{ResultExt, paths::PathStyle, rel_path::RelPath};
 
 #[derive(Default)]
 pub(crate) struct PythonDebugAdapter {
@@ -92,12 +93,16 @@ impl PythonDebugAdapter {
         })
     }
 
-    async fn fetch_wheel(&self, delegate: &Arc<dyn DapDelegate>) -> Result<Arc<Path>, String> {
+    async fn fetch_wheel(
+        &self,
+        toolchain: Option<Toolchain>,
+        delegate: &Arc<dyn DapDelegate>,
+    ) -> Result<Arc<Path>> {
         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 system_python = self.base_venv_path(delegate).await?;
+        std::fs::create_dir_all(&download_dir)?;
+        let venv_python = self.base_venv_path(toolchain, delegate).await?;
 
-        let installation_succeeded = util::command::new_smol_command(system_python.as_ref())
+        let installation_succeeded = util::command::new_smol_command(venv_python.as_ref())
             .args([
                 "-m",
                 "pip",
@@ -109,36 +114,36 @@ impl PythonDebugAdapter {
             ])
             .output()
             .await
-            .map_err(|e| format!("{e}"))?
+            .context("spawn system python")?
             .status
             .success();
         if !installation_succeeded {
-            return Err("debugpy installation failed (could not fetch Debugpy's wheel)".into());
+            bail!("debugpy installation failed (could not fetch Debugpy's wheel)");
         }
 
-        let wheel_path = std::fs::read_dir(&download_dir)
-            .map_err(|e| e.to_string())?
+        let wheel_path = std::fs::read_dir(&download_dir)?
             .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}"))?;
+            .with_context(|| format!("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())?,
+            File::open(&wheel_path.path()).await?,
         )
-        .await
-        .map_err(|e| e.to_string())?;
+        .await?;
 
         Ok(Arc::from(wheel_path.path()))
     }
 
-    async fn maybe_fetch_new_wheel(&self, delegate: &Arc<dyn DapDelegate>) {
+    async fn maybe_fetch_new_wheel(
+        &self,
+        toolchain: Option<Toolchain>,
+        delegate: &Arc<dyn DapDelegate>,
+    ) -> Result<()> {
         let latest_release = delegate
             .http_client()
             .get(
@@ -148,62 +153,61 @@ impl PythonDebugAdapter {
             )
             .await
             .log_err();
-        maybe!(async move {
-            let response = latest_release.filter(|response| response.status().is_success())?;
-
-            let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME);
-            std::fs::create_dir_all(&download_dir).ok()?;
-
-            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| {
+        let response = latest_release
+            .filter(|response| response.status().is_success())
+            .context("getting latest release")?;
+
+        let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME);
+        std::fs::create_dir_all(&download_dir)?;
+
+        let mut output = String::new();
+        response.into_body().read_to_string(&mut output).await?;
+        let as_json = serde_json::Value::from_str(&output)?;
+        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;
+            })
+            .context("parsing latest release information")?;
+        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?
+            .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()?;
-            }
-            Some(())
-        })
-        .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?;
+            self.fetch_wheel(toolchain, delegate).await?;
+        }
+        anyhow::Ok(())
     }
 
     async fn fetch_debugpy_whl(
         &self,
+        toolchain: Option<Toolchain>,
         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;
+                self.maybe_fetch_new_wheel(toolchain, delegate)
+                    .await
+                    .map_err(|e| format!("{e}"))?;
                 Ok(Arc::from(
                     debug_adapters_dir()
                         .join(Self::ADAPTER_NAME)
@@ -216,12 +220,24 @@ impl PythonDebugAdapter {
             .clone()
     }
 
-    async fn base_venv_path(&self, delegate: &Arc<dyn DapDelegate>) -> Result<Arc<Path>, String> {
-        self.base_venv_path
+    async fn base_venv_path(
+        &self,
+        toolchain: Option<Toolchain>,
+        delegate: &Arc<dyn DapDelegate>,
+    ) -> Result<Arc<Path>> {
+        let result = self.base_venv_path
             .get_or_init(|| async {
-                let base_python = Self::system_python_name(delegate)
-                    .await
-                    .ok_or_else(|| String::from("Could not find a Python installation"))?;
+                let base_python = if let Some(toolchain) = toolchain {
+                    toolchain.path.to_string()
+                } else {
+                    Self::system_python_name(delegate).await.ok_or_else(|| {
+                        let mut message = "Could not find a Python installation".to_owned();
+                        if cfg!(windows){
+                            message.push_str(". Install Python from the Microsoft Store, or manually from https://www.python.org/downloads/windows.")
+                        }
+                        message
+                    })?
+                };
 
                 let did_succeed = util::command::new_smol_command(base_python)
                     .args(["-m", "venv", "zed_base_venv"])
@@ -239,35 +255,50 @@ impl PythonDebugAdapter {
                     return Err("Failed to create base virtual environment".into());
                 }
 
-                const DIR: &str = if cfg!(target_os = "windows") {
-                    "Scripts"
+                const PYTHON_PATH: &str = if cfg!(target_os = "windows") {
+                    "Scripts/python.exe"
                 } else {
-                    "bin"
+                    "bin/python3"
                 };
                 Ok(Arc::from(
                     paths::debug_adapters_dir()
                         .join(Self::DEBUG_ADAPTER_NAME.as_ref())
                         .join("zed_base_venv")
-                        .join(DIR)
-                        .join("python3")
+                        .join(PYTHON_PATH)
                         .as_ref(),
                 ))
             })
             .await
-            .clone()
+            .clone();
+        match result {
+            Ok(path) => Ok(path),
+            Err(e) => Err(anyhow::anyhow!("{e}")),
+        }
     }
     async fn system_python_name(delegate: &Arc<dyn DapDelegate>) -> Option<String> {
         const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
         let mut name = None;
 
         for cmd in BINARY_NAMES {
-            name = delegate
-                .which(OsStr::new(cmd))
+            let Some(path) = delegate.which(OsStr::new(cmd)).await else {
+                continue;
+            };
+            // Try to detect situations where `python3` exists but is not a real Python interpreter.
+            // Notably, on fresh Windows installs, `python3` is a shim that opens the Microsoft Store app
+            // when run with no arguments, and just fails otherwise.
+            let Some(output) = new_smol_command(&path)
+                .args(["-c", "print(1 + 2)"])
+                .output()
                 .await
-                .map(|path| path.to_string_lossy().into_owned());
-            if name.is_some() {
-                break;
+                .ok()
+            else {
+                continue;
+            };
+            if output.stdout.trim_ascii() != b"3" {
+                continue;
             }
+            name = Some(path.to_string_lossy().into_owned());
+            break;
         }
         name
     }
@@ -746,15 +777,10 @@ impl DebugAdapter for PythonDebugAdapter {
             )
             .await;
 
-        let debugpy_path = self
-            .fetch_debugpy_whl(delegate)
+        self.fetch_debugpy_whl(toolchain.clone(), delegate)
             .await
             .map_err(|e| anyhow::anyhow!("{e}"))?;
         if let Some(toolchain) = &toolchain {
-            log::debug!(
-                "Found debugpy in toolchain environment: {}",
-                debugpy_path.display()
-            );
             return self
                 .get_installed_binary(
                     delegate,

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -268,12 +268,12 @@ impl DebugPanel {
 
             async move |_, cx| {
                 if let Err(error) = task.await {
-                    log::error!("{error}");
+                    log::error!("{error:#}");
                     session
                         .update(cx, |session, cx| {
                             session
                                 .console_output(cx)
-                                .unbounded_send(format!("error: {}", error))
+                                .unbounded_send(format!("error: {:#}", error))
                                 .ok();
                             session.shutdown(cx)
                         })?

crates/languages/src/python.rs 🔗

@@ -23,6 +23,7 @@ use serde_json::{Value, json};
 use smol::lock::OnceCell;
 use std::cmp::Ordering;
 use std::env::consts;
+use util::command::new_smol_command;
 use util::fs::{make_file_executable, remove_matching};
 use util::rel_path::RelPath;
 
@@ -1332,7 +1333,13 @@ impl PyLspAdapter {
     async fn ensure_venv(delegate: &dyn LspAdapterDelegate) -> Result<Arc<Path>> {
         let python_path = Self::find_base_python(delegate)
             .await
-            .context("Could not find Python installation for PyLSP")?;
+            .with_context(|| {
+                let mut message = "Could not find Python installation for PyLSP".to_owned();
+                if cfg!(windows){
+                    message.push_str(". Install Python from the Microsoft Store, or manually from https://www.python.org/downloads/windows.")
+                }
+                message
+            })?;
         let work_dir = delegate
             .language_server_download_dir(&Self::SERVER_NAME)
             .await
@@ -1355,9 +1362,24 @@ impl PyLspAdapter {
     // Find "baseline", user python version from which we'll create our own venv.
     async fn find_base_python(delegate: &dyn LspAdapterDelegate) -> Option<PathBuf> {
         for path in ["python3", "python"] {
-            if let Some(path) = delegate.which(path.as_ref()).await {
-                return Some(path);
+            let Some(path) = delegate.which(path.as_ref()).await else {
+                continue;
+            };
+            // Try to detect situations where `python3` exists but is not a real Python interpreter.
+            // Notably, on fresh Windows installs, `python3` is a shim that opens the Microsoft Store app
+            // when run with no arguments, and just fails otherwise.
+            let Some(output) = new_smol_command(&path)
+                .args(["-c", "print(1 + 2)"])
+                .output()
+                .await
+                .ok()
+            else {
+                continue;
+            };
+            if output.stdout.trim_ascii() != b"3" {
+                continue;
             }
+            return Some(path);
         }
         None
     }