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.
This commit is contained in:
Cole Miller
2025-10-13 21:11:33 +00:00
committed by GitHub
parent f0d097c66a
commit 6a1648825c
4 changed files with 135 additions and 87 deletions
+108 -82
View File
@@ -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 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).ok()?;
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
.ok()?;
let as_json = serde_json::Value::from_str(&output).ok()?;
let latest_version = as_json.get("info").and_then(|info| {
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,
+2 -2
View File
@@ -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)
})?
+25 -3
View File
@@ -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
}
View File