diff --git a/crates/uv/src/commands/diagnostics.rs b/crates/uv/src/commands/diagnostics.rs index 2ee04220a46cf..6e3e101ea21a1 100644 --- a/crates/uv/src/commands/diagnostics.rs +++ b/crates/uv/src/commands/diagnostics.rs @@ -1,5 +1,7 @@ use std::str::FromStr; use std::sync::{Arc, LazyLock}; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; use owo_colors::OwoColorize; use rustc_hash::FxHashMap; @@ -477,3 +479,22 @@ fn format_chain(name: &PackageName, version: Option<&Version>, chain: &Derivatio } message } + + +pub(crate) fn extract_python_from_shebang(script_path: &Path) -> Option { + let file = std::fs::File::open(script_path).ok()?; + let mut reader = BufReader::new(file); + let mut first_line = String::new(); + reader.read_line(&mut first_line).ok()?; + + // Check if it starts with shebang + let shebang = first_line.strip_prefix("#!")?; + let shebang = shebang.trim(); + + if shebang.contains("python") { + let interpreter = shebang.split_whitespace().next()?; + Some(PathBuf::from(interpreter)) + } else { + None + } +} diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index dc8b201d0f6e8..f9544ccf49a20 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1319,9 +1319,30 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl // Spawn and wait for completion // Standard input, output, and error streams are all inherited // TODO(zanieb): Throw a nicer error message if the command is not found - let handle = process - .spawn() - .with_context(|| format!("Failed to spawn: `{}`", command.display_executable()))?; + let handle = match process.spawn() { + Ok(handle) => Ok(handle), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + // Check if the error is due to a missing Python interpreter in the script's shebang. + let script_path = interpreter.scripts().join(command.executable()); + if script_path.exists() { + if let Some(python_path) = crate::commands::diagnostics::extract_python_from_shebang(&script_path) { + if !python_path.exists() { + writeln!( + printer.stderr(), + "{}: `{}` uses a Python interpreter at `{}` which no longer exists", + "hint".cyan().bold(), + command.display_executable(), + python_path.display(), + )?; + } + } + } + + Err(err) + } + Err(err) => Err(err), + } + .with_context(|| format!("Failed to spawn command `{}`", command.display_executable()))?; run_to_completion(handle).await } diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 60e96f3f83974..d28977a3cfc03 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -58,6 +58,7 @@ use crate::commands::reporters::PythonDownloadReporter; use crate::commands::tool::common::{matching_packages, refine_interpreter}; use crate::commands::tool::{Target, ToolRequest}; use crate::commands::{diagnostics, project::environment::CachedEnvironment}; +use crate::commands::diagnostics::extract_python_from_shebang; use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; use crate::settings::ResolverSettings; @@ -433,6 +434,23 @@ pub(crate) async fn run( return Ok(ExitStatus::Failure); } } + + // Check if the error is due to a missing Python interpreter in the script's shebang. + let script_path = environment.scripts().join(executable); + if script_path.exists() { + if let Some(python_path) = extract_python_from_shebang(&script_path) { + if !python_path.exists() { + writeln!( + printer.stderr(), + "{}: `{}` uses a Python interpreter at `{}` which no longer exists", + "hint".cyan().bold(), + executable, + python_path.display(), + )?; + } + } + } + Err(err) } Err(err) => Err(err),