From 6a1648825c49ddc6c385bdb3cf269b8910b967bc Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 13 Oct 2025 17:11:33 -0400 Subject: [PATCH] windows: Detect when `python3` is not usable and notify the user (#40070) 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. --- 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(-) create mode 100644 main.py diff --git a/crates/dap_adapters/src/python.rs b/crates/dap_adapters/src/python.rs index 47aec4aa5b1a39a517b7887828200ebf4bd065d4..db9d66a31d895f3f52d477dfdef424131221edb9 100644 --- a/crates/dap_adapters/src/python.rs +++ b/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) -> Result, String> { + async fn fetch_wheel( + &self, + toolchain: Option, + delegate: &Arc, + ) -> Result> { 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) { + async fn maybe_fetch_new_wheel( + &self, + toolchain: Option, + delegate: &Arc, + ) -> 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, delegate: &Arc, ) -> Result, 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) -> Result, String> { - self.base_venv_path + async fn base_venv_path( + &self, + toolchain: Option, + delegate: &Arc, + ) -> Result> { + 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) -> Option { 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, diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 9154047aa54b43a726834e62a3a4a397ae91d74b..093cef3630e3ae3627a2999b9deb81be3b0aeb8d 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/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) })? diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 2ac4a5b9f543576944e1ce30b52593afaef8d34a..ecd3dd3ec94711758a26a96d19ebd4ebad3bf5a4 100644 --- a/crates/languages/src/python.rs +++ b/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> { 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 { 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 } diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391