diff --git a/Cargo.lock b/Cargo.lock index 83c1cd5dff3f8..6c0a4b4799a96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5042,6 +5042,8 @@ dependencies = [ "uv-state", "uv-warnings", "which", + "windows-registry", + "windows-result", "windows-sys 0.59.0", "winsafe 0.0.22", ] diff --git a/Cargo.toml b/Cargo.toml index 155da3443de17..7557cc46c0b5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,8 @@ url = { version = "2.5.0" } urlencoding = { version = "2.1.3" } walkdir = { version = "2.5.0" } which = { version = "6.0.0", features = ["regex"] } +windows-registry = { version = "0.2.0" } +windows-result = { version = "0.2.0" } windows-sys = { version = "0.59.0", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Ioctl", "Win32_System_IO"] } winreg = { version = "0.52.0" } winsafe = { version = "0.0.22", features = ["kernel"] } diff --git a/crates/uv-python/Cargo.toml b/crates/uv-python/Cargo.toml index 57bcd183feaed..c3c17a6ce9527 100644 --- a/crates/uv-python/Cargo.toml +++ b/crates/uv-python/Cargo.toml @@ -58,6 +58,8 @@ rustix = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { workspace = true } winsafe = { workspace = true } +windows-registry = { workspace = true } +windows-result = { workspace = true } [dev-dependencies] anyhow = { version = "1.0.80" } diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index f0b4876ac5d7f..8c9aa5160b941 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -20,7 +20,7 @@ use crate::implementation::ImplementationName; use crate::installation::PythonInstallation; use crate::interpreter::Error as InterpreterError; use crate::managed::ManagedPythonInstallations; -use crate::py_launcher::{self, py_list_paths}; +use crate::py_launcher::registry_pythons; use crate::virtualenv::{ conda_prefix_from_env, virtualenv_from_env, virtualenv_from_working_dir, virtualenv_python_executable, @@ -189,9 +189,8 @@ pub enum Error { #[error(transparent)] VirtualEnv(#[from] crate::virtualenv::Error), - /// An error was encountered when using the `py` launcher on Windows. - #[error(transparent)] - PyLauncher(#[from] crate::py_launcher::Error), + #[error("Failed to query installed Python versions from the Windows registry")] + RegistryError(#[from] windows_result::Error), /// An invalid version request was given #[error("Invalid version request: {0}")] @@ -311,15 +310,24 @@ fn python_executables_from_installed<'a>( let from_py_launcher = std::iter::once_with(move || { (cfg!(windows) && env::var_os("UV_TEST_PYTHON_PATH").is_none()) .then(|| { - py_list_paths() - .map(|entries| - // We can avoid querying the interpreter using versions from the py launcher output unless a patch is requested - entries.into_iter().filter(move |entry| - version.is_none() || version.is_some_and(|version| - version.has_patch() || version.matches_major_minor(entry.major, entry.minor) - ) - ) - .map(|entry| (PythonSource::PyLauncher, entry.executable_path))) + registry_pythons() + // We can avoid querying the interpreter using versions from the py launcher output unless a patch is requested + .map(|entries| { + entries + .into_iter() + .filter(move |entry| { + if let Some(version_request) = version { + if let Some(version) = &entry.version { + version_request.matches_version(version) + } else { + true + } + } else { + true + } + }) + .map(|entry| (PythonSource::PyLauncher, entry.path)) + }) .map_err(Error::from) }) .into_iter() @@ -626,11 +634,6 @@ impl Error { false } }, - // Ignore `py` if it's not installed - Error::PyLauncher(py_launcher::Error::NotFound) => { - debug!("The `py` launcher could not be found to query for Python versions"); - false - } _ => true, } } diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index 4a44d2c341c3c..340527a17832b 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -60,9 +60,6 @@ pub enum Error { #[error(transparent)] Discovery(#[from] discovery::Error), - #[error(transparent)] - PyLauncher(#[from] py_launcher::Error), - #[error(transparent)] ManagedPython(#[from] managed::Error), diff --git a/crates/uv-python/src/py_launcher.rs b/crates/uv-python/src/py_launcher.rs index e7cc4a7b7625c..aea2ffaecb7eb 100644 --- a/crates/uv-python/src/py_launcher.rs +++ b/crates/uv-python/src/py_launcher.rs @@ -1,93 +1,114 @@ -use regex::Regex; -use std::io; +use crate::PythonVersion; use std::path::PathBuf; -use std::process::{Command, ExitStatus}; -use std::sync::LazyLock; -use thiserror::Error; -use tracing::info_span; +use std::str::FromStr; +use tracing::debug; +use windows_registry::{Key, Value, CURRENT_USER, LOCAL_MACHINE}; +/// A Python interpreter found in the Windows registry through PEP 514. +/// +/// There are a lot more (optional) fields defined in PEP 514, but we only care about path and +/// version here, for everything else we probe with a Python script. #[derive(Debug, Clone)] -pub(crate) struct PyListPath { - pub(crate) major: u8, - pub(crate) minor: u8, - pub(crate) executable_path: PathBuf, +pub(crate) struct RegistryPython { + pub(crate) path: PathBuf, + pub(crate) version: Option, } -/// An error was encountered when using the `py` launcher on Windows. -#[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, +/// Àdding `windows_registry::Value::into_string()`. +fn value_to_string(value: Value) -> Option { + match value { + Value::String(string) => Some(string), + Value::Bytes(bytes) => String::from_utf8(bytes.to_vec()).ok(), + Value::U32(_) | Value::U64(_) | Value::MultiString(_) | Value::Unknown(_) => None, + } } -/// ```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: LazyLock = LazyLock::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) +/// Find all Pythons registered in the Windows registry following PEP 514. +pub(crate) fn registry_pythons() -> Result, windows_result::Error> { + let mut registry_pythons = Vec::new(); + for root_key in [CURRENT_USER, LOCAL_MACHINE] { + let Ok(key_python) = root_key.open(r"Software\Python") else { + continue; + }; + for company in key_python.keys()? { + // Reserved name according to the PEP. + if company == "PyLauncher" { + continue; } - })?; + let Ok(company_key) = key_python.open(&company) else { + // Ignore invalid entries + continue; + }; + for tag in company_key.keys()? { + let tag_key = company_key.open(&tag)?; - // `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(), - }); + if let Some(registry_python) = read_registry_entry(&company, &tag, &tag_key) { + registry_pythons.push(registry_python); + } + } + } } - // 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(), - })?; + // The registry has no natural ordering, so we're processing the latest version first. + registry_pythons.sort_by(|a, b| { + // Highest version first (reverse), but missing versions at the bottom (regular order). + if let (Some(version_a), Some(version_b)) = (&a.version, &b.version) { + version_a + .cmp(&version_b) + .reverse() + .then(a.path.cmp(&b.path)) + } else { + a.version + .as_ref() + .map(|version| &***version) + .cmp(&b.version.as_ref().map(|version| &***version)) + .then(a.path.cmp(&b.path)) + } + }); - 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 { + Ok(registry_pythons) +} + +fn read_registry_entry(company: &str, tag: &str, tag_key: &Key) -> Option { + // `ExecutablePath` is mandatory for executable Pythons. + let Some(executable_path) = tag_key + .open("InstallPath") + .and_then(|install_path| install_path.get_value("ExecutablePath")) + .ok() + .and_then(value_to_string) + else { + debug!( + r"Registry Python is not executable: `Software\Python\{}\{}", + company, tag + ); + return None; + }; + debug!( + "Found registry Python {} {}: `{}`", + company, tag, executable_path + ); + + // `SysVersion` is optional. + let version = tag_key + .get_value("SysVersion") + .ok() + .and_then(|value| match value { + Value::String(s) => Some(s), + _ => None, + }) + .and_then(|s| match PythonVersion::from_str(&s) { + Ok(version) => Some(version), + Err(err) => { + debug!( + "Invalid registry Python version at {}: {}", + executable_path, err + ); None } - }) - .collect()) + }); + + Some(RegistryPython { + path: PathBuf::from(executable_path), + version, + }) }