diff --git a/crates/uv-interpreter/src/find_python.rs b/crates/uv-interpreter/src/find_python.rs index d2090a5c71ff..494fdd5cb68d 100644 --- a/crates/uv-interpreter/src/find_python.rs +++ b/crates/uv-interpreter/src/find_python.rs @@ -10,6 +10,7 @@ use uv_warnings::warn_user_once; use crate::environment::python_environment::{detect_python_executable, detect_virtual_env}; use crate::interpreter::InterpreterInfoError; +use crate::py_launcher::{py_list_paths, Error as PyLauncherError, PyListPath}; use crate::PythonVersion; use crate::{Error, Interpreter}; @@ -202,7 +203,7 @@ fn find_python( if cfg!(windows) && !use_override { // Use `py` to find the python installation on the system. - match windows::py_list_paths() { + match py_list_paths() { Ok(paths) => { for entry in paths { let installation = PythonInstallation::PyListPath(entry); @@ -211,12 +212,9 @@ fn find_python( } } } - Err(Error::PyList(error)) => { - if error.kind() == std::io::ErrorKind::NotFound { - debug!("`py` is not installed"); - } - } - Err(error) => return Err(error), + // Do not error when `py` is not available + Err(PyLauncherError::NotFound) => debug!("`py` is not installed"), + Err(error) => return Err(Error::PyLauncher(error)), } } @@ -263,7 +261,7 @@ fn find_executable + Into + Copy>( if cfg!(windows) && !use_override { // Use `py` to find the python installation on the system. - match windows::py_list_paths() { + match py_list_paths() { Ok(paths) => { for entry in paths { // Ex) `--python python3.12.exe` @@ -281,25 +279,15 @@ fn find_executable + Into + Copy>( } } } - Err(Error::PyList(error)) => { - if error.kind() == std::io::ErrorKind::NotFound { - debug!("`py` is not installed"); - } - } - Err(error) => return Err(error), + // Do not error when `py` is not available + Err(PyLauncherError::NotFound) => debug!("`py` is not installed"), + Err(error) => return Err(Error::PyLauncher(error)), } } Ok(None) } -#[derive(Debug, Clone)] -struct PyListPath { - major: u8, - minor: u8, - executable_path: PathBuf, -} - #[derive(Debug, Clone)] enum PythonInstallation { PyListPath(PyListPath), @@ -545,75 +533,6 @@ fn find_version( } mod windows { - use std::path::PathBuf; - use std::process::Command; - - use once_cell::sync::Lazy; - use regex::Regex; - use tracing::info_span; - - use crate::find_python::PyListPath; - use crate::Error; - - /// ```text - /// -V:3.12 C:\Users\Ferris\AppData\Local\Programs\Python\Python312\python.exe - /// -V:3.8 C:\Users\Ferris\AppData\Local\Programs\Python\Python38\python.exe - /// ``` - static PY_LIST_PATHS: Lazy = Lazy::new(|| { - // Without the `R` flag, paths have trailing \r - Regex::new(r"(?mR)^ -(?:V:)?(\d).(\d+)-?(?:arm)?\d*\s*\*?\s*(.*)$").unwrap() - }); - - /// Run `py --list-paths` to find the installed pythons. - /// - /// The command takes 8ms on my machine. - /// TODO(konstin): Implement to read python installations from the registry instead. - pub(super) fn py_list_paths() -> Result, Error> { - let output = info_span!("py_list_paths") - .in_scope(|| Command::new("py").arg("--list-paths").output()) - .map_err(Error::PyList)?; - - // `py` sometimes prints "Installed Pythons found by py Launcher for Windows" to stderr which we ignore. - if !output.status.success() { - return Err(Error::PythonSubcommandOutput { - message: format!( - "Running `py --list-paths` failed with status {}", - output.status - ), - exit_code: output.status, - stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), - }); - } - - // Find the first python of the version we want in the list - let stdout = - String::from_utf8(output.stdout).map_err(|err| Error::PythonSubcommandOutput { - message: format!("The stdout of `py --list-paths` isn't UTF-8 encoded: {err}"), - exit_code: output.status, - stdout: String::from_utf8_lossy(err.as_bytes()).trim().to_string(), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), - })?; - - Ok(PY_LIST_PATHS - .captures_iter(&stdout) - .filter_map(|captures| { - let (_, [major, minor, path]) = captures.extract(); - if let (Some(major), Some(minor)) = - (major.parse::().ok(), minor.parse::().ok()) - { - Some(PyListPath { - major, - minor, - executable_path: PathBuf::from(path), - }) - } else { - None - } - }) - .collect()) - } - /// On Windows we might encounter the Windows Store proxy shim (enabled in: /// Settings/Apps/Advanced app settings/App execution aliases). When Python is _not_ installed /// via the Windows Store, but the proxy shim is enabled, then executing `python.exe` or diff --git a/crates/uv-interpreter/src/lib.rs b/crates/uv-interpreter/src/lib.rs index 4c0fb9fb45d2..8e507cd07376 100644 --- a/crates/uv-interpreter/src/lib.rs +++ b/crates/uv-interpreter/src/lib.rs @@ -27,6 +27,7 @@ mod environment; mod find_python; mod interpreter; pub mod managed; +mod py_launcher; mod python_version; pub mod selectors; mod target; @@ -49,8 +50,8 @@ pub enum Error { #[source] err: io::Error, }, - #[error("Failed to run `py --list-paths` to find Python installations. Is Python installed?")] - PyList(#[source] io::Error), + #[error(transparent)] + PyLauncher(#[from] py_launcher::Error), #[cfg(windows)] #[error( "No Python {0} found through `py --list-paths` or in `PATH`. Is Python {0} installed?" diff --git a/crates/uv-interpreter/src/py_launcher.rs b/crates/uv-interpreter/src/py_launcher.rs new file mode 100644 index 000000000000..24a59282f9af --- /dev/null +++ b/crates/uv-interpreter/src/py_launcher.rs @@ -0,0 +1,126 @@ +use std::io; +use std::path::PathBuf; +use std::process::{Command, ExitStatus}; + +use once_cell::sync::Lazy; +use regex::Regex; +use thiserror::Error; +use tracing::info_span; + +#[derive(Debug, Clone)] +pub(crate) struct PyListPath { + pub(crate) major: u8, + pub(crate) minor: u8, + pub(crate) executable_path: PathBuf, +} + +#[derive(Error, Debug)] +pub enum Error { + #[error("{message} with {exit_code}\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---")] + StatusCode { + message: String, + exit_code: ExitStatus, + stdout: String, + stderr: String, + }, + #[error("Failed to run `py --list-paths` to find Python installations.")] + Io(#[source] io::Error), + #[error("The `py` launcher could not be found.")] + NotFound, +} + +/// ```text +/// -V:3.12 C:\Users\Ferris\AppData\Local\Programs\Python\Python312\python.exe +/// -V:3.8 C:\Users\Ferris\AppData\Local\Programs\Python\Python38\python.exe +/// ``` +static PY_LIST_PATHS: Lazy = Lazy::new(|| { + // Without the `R` flag, paths have trailing \r + Regex::new(r"(?mR)^ -(?:V:)?(\d).(\d+)-?(?:arm)?\d*\s*\*?\s*(.*)$").unwrap() +}); + +/// Use the `py` launcher to find installed Python versions. +/// +/// Calls `py --list-paths`. +pub(crate) fn py_list_paths() -> Result, Error> { + // konstin: The command takes 8ms on my machine. + let output = info_span!("py_list_paths") + .in_scope(|| Command::new("py").arg("--list-paths").output()) + .map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + Error::NotFound + } else { + Error::Io(err) + } + })?; + + // `py` sometimes prints "Installed Pythons found by py Launcher for Windows" to stderr which we ignore. + if !output.status.success() { + return Err(Error::StatusCode { + message: format!( + "Running `py --list-paths` failed with status {}", + output.status + ), + exit_code: output.status, + stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }); + } + + // Find the first python of the version we want in the list + let stdout = String::from_utf8(output.stdout).map_err(|err| Error::StatusCode { + message: format!("The stdout of `py --list-paths` isn't UTF-8 encoded: {err}"), + exit_code: output.status, + stdout: String::from_utf8_lossy(err.as_bytes()).trim().to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + })?; + + Ok(PY_LIST_PATHS + .captures_iter(&stdout) + .filter_map(|captures| { + let (_, [major, minor, path]) = captures.extract(); + if let (Some(major), Some(minor)) = (major.parse::().ok(), minor.parse::().ok()) + { + Some(PyListPath { + major, + minor, + executable_path: PathBuf::from(path), + }) + } else { + None + } + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use std::fmt::Debug; + + use insta::assert_snapshot; + use itertools::Itertools; + + use uv_cache::Cache; + + use crate::{find_requested_python, Error}; + + fn format_err(err: Result) -> String { + anyhow::Error::new(err.unwrap_err()) + .chain() + .join("\n Caused by: ") + } + + #[test] + #[cfg_attr(not(windows), ignore)] + fn no_such_python_path() { + let result = + find_requested_python(r"C:\does\not\exists\python3.12", &Cache::temp().unwrap()) + .unwrap() + .ok_or(Error::RequestedPythonNotFound( + r"C:\does\not\exists\python3.12".to_string(), + )); + assert_snapshot!( + format_err(result), + @"Failed to locate Python interpreter at `C:\\does\\not\\exists\\python3.12`" + ); + } +}