Skip to content

Commit

Permalink
Use windows registry to discover python
Browse files Browse the repository at this point in the history
Our current strategy of parsing the output of `py --list-paths` to get the installed python versions on windows is brittle (#6524, missing `py`, etc.) and it's slow (10ms last time i measured).

Instead, we should behave spec-compliant and read the python versions from the registry following PEP 514.

We're using the official rust-for-windows crates for accessing the registry.

Fixes #1521
Fixes #6524
  • Loading branch information
konstin committed Aug 28, 2024
1 parent 451eef3 commit e950624
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 99 deletions.
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
2 changes: 2 additions & 0 deletions crates/uv-python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
39 changes: 21 additions & 18 deletions crates/uv-python/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}")]
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
}
}
Expand Down
3 changes: 0 additions & 3 deletions crates/uv-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
177 changes: 99 additions & 78 deletions crates/uv-python/src/py_launcher.rs
Original file line number Diff line number Diff line change
@@ -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<PythonVersion>,
}

/// 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<String> {
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<Regex> = 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<Vec<PyListPath>, 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<Vec<RegistryPython>, 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::<u8>().ok(), minor.parse::<u8>().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<RegistryPython> {
// `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,
})
}

0 comments on commit e950624

Please sign in to comment.