From dce57ca83a5bad1105b9605b73ba158d5ad1d7a5 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:59:03 +0800 Subject: [PATCH] fix(tools): find Python on Windows without python3.exe (#1427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tools): find Python on Windows without python3.exe Installing debugpy on Windows failed with "cannot run python3.exe … (os error 3)": the tool manager looked for `python3` first, and on Windows that only finds the App Execution Alias in WindowsApps, which can't start without the Store's Python. Windows installs `python.exe` and the `py` launcher instead. On Windows the interpreter is now looked up as `python`, `py`, then `python3`, and a WindowsApps alias only counts when it runs. The debugpy adapter's default interpreter is `python` on Windows too. macOS and Linux keep `python3` first. * fix(tools): keep searching past broken Python aliases --- crates/ide-dap/src/adapter.rs | 8 +++++++- crates/ide-tools/src/lib.rs | 31 ++++++++++++++++++++++++++++++- crates/ide-tools/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index 2f4f88fc1..25c44cdd6 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -72,7 +72,13 @@ fn builtin(adapter: &str) -> Option { "stdio", "Install the Kotlin Debug Adapter from Settings > Language Servers", ), - "debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"), + // Windows installs `python.exe`; `python3.exe` there is at most a Store alias. + "debugpy" => cfg( + if cfg!(windows) { "python" } else { "python3" }, + &["-m", "debugpy.adapter"], + "stdio", + "pip install debugpy", + ), "js-debug" => cfg( "js-debug-adapter", // Without a host it listens on `localhost`, which may resolve to ::1 only. diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 99c82ed25..881393d6c 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -395,6 +395,11 @@ pub fn parse_version(text: &str) -> Option { None } +/// A Windows App Execution Alias (`%LOCALAPPDATA%\Microsoft\WindowsApps\python.exe`). +fn is_app_execution_alias(path: &Path) -> bool { + path.parent().and_then(Path::file_name).is_some_and(|d| d.eq_ignore_ascii_case("WindowsApps")) +} + /// A tool whose "command" is a plugin jar (e.g. a jdtls bundle) rather than an executable. fn is_bundle(command: &str) -> bool { command.ends_with(".jar") @@ -491,8 +496,32 @@ impl Tools { cmd } + /// The interpreter that installs and probes Python tools. Windows installs `python.exe` + /// and the `py` launcher, not `python3.exe`; its `WindowsApps` entries are App Execution + /// Aliases that only open the Store unless the Store's Python is installed, so they count + /// only when they run. fn python(&self) -> Option { - self.which_any(&["python3", "python"]) + if self.platform.os != Os::Windows { + return self.which_any(&["python3", "python"]); + } + ["python", "py", "python3"] + .into_iter() + .flat_map(|command| { + std::env::split_paths(&self.system_path).filter_map(move |dir| { + if dir.as_os_str().is_empty() { + return None; + } + let search = std::env::join_paths([dir]).ok()?; + ide_core::tool_path::find_command(command, &search, &self.root) + }) + }) + .find(|p| { + !is_app_execution_alias(p) || { + let mut c = self.python_command(p); + c.arg("--version"); + run_quiet(c, Duration::from_secs(5)).is_some_and(|(ok, _)| ok) + } + }) } /// Executable `cmd` inside `dir`, honouring Windows suffixes. diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index b992f2186..de4ff670d 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -37,6 +37,12 @@ impl Env { fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap(); archive::make_executable(&p).unwrap(); } + + #[cfg(windows)] + fn script(&self, dir: &Path, name: &str, body: &str) { + fs::create_dir_all(dir).unwrap(); + fs::write(dir.join(format!("{name}.cmd")), format!("@echo off\r\n{body}\r\n")).unwrap(); + } } fn find<'a>(list: &'a [ToolInfo], name: &str) -> &'a ToolInfo { @@ -700,6 +706,34 @@ fn rust_analyzer_managed_copy_survives_rustup_becoming_available() { assert!(!env.root.join("rust-analyzer").exists()); } +/// Windows has `python.exe` and `py`, not `python3.exe`; the `WindowsApps` aliases only count +/// when the Store's Python behind them runs. +#[test] +fn windows_python_skips_store_aliases_that_do_not_run() { + let env = Env::new(); + let aliases = env.sys.parent().unwrap().join("Microsoft").join("WindowsApps"); + env.script(&aliases, "python", "exit 9009"); + env.script(&aliases, "python3", "exit 9009"); + env.script(&env.sys, "py", "echo Python 3.12.0"); + let windows = Platform { os: Os::Windows, arch: platform::Arch::X64 }; + let found = || { + let dirs = std::env::join_paths([aliases.clone(), env.sys.clone()]).unwrap(); + Tools::with_paths(env.root.clone(), dirs, windows).python().map(|python| { + let dir = python.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned(); + format!("{dir}/{}", python.file_stem().unwrap().to_string_lossy()) + }) + }; + assert_eq!(found().as_deref(), Some("sysbin/py")); + fs::remove_file(env.sys.join(if cfg!(windows) { "py.cmd" } else { "py" })).unwrap(); + assert_eq!(found(), None, "only broken aliases are available"); + env.script(&env.sys, "python3", "echo Python 3.12.0"); + assert_eq!(found().as_deref(), Some("sysbin/python3"), "python3 is the final fallback"); + env.script(&env.sys, "python", "echo Python 3.12.0"); + assert_eq!(found().as_deref(), Some("sysbin/python"), "a broken alias must not hide a later interpreter"); + env.script(&aliases, "python", "echo Python 3.13.0"); + assert_eq!(found().as_deref(), Some("WindowsApps/python"), "the Store's Python runs"); +} + #[cfg(unix)] #[test] fn python_probes_run_in_an_empty_private_directory() {