Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use windows registry to discover python #6761

Merged
merged 7 commits into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
74 changes: 44 additions & 30 deletions crates/uv-python/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ 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};
#[cfg(windows)]
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 @@ -164,8 +165,8 @@ pub enum PythonSource {
DiscoveredEnvironment,
/// An executable was found in the search path i.e. `PATH`
SearchPath,
/// An executable was found via the `py` launcher
PyLauncher,
/// An executable was found in the Windows registry via PEP 514
Registry,
/// The Python installation was found in the uv managed Python directory
Managed,
/// The Python installation was found via the invoking interpreter i.e. via `python -m uv ...`
Expand All @@ -189,9 +190,9 @@ 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),
#[cfg(windows)]
#[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 @@ -309,21 +310,39 @@ fn python_executables_from_installed<'a>(

// TODO(konstin): Implement <https://peps.python.org/pep-0514/> to read python installations from the registry instead.
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)))
.map_err(Error::from)
})
.into_iter()
.flatten_ok()
#[cfg(windows)]
{
env::var_os("UV_TEST_PYTHON_PATH")
.is_none()
.then(|| {
registry_pythons()
.map(|entries| {
entries
.into_iter()
.filter(move |entry| {
// Skip interpreter probing if we already know the version
// doesn't match.
if let Some(version_request) = version {
if let Some(version) = &entry.version {
version_request.matches_version(version)
} else {
true
}
} else {
true
}
})
.map(|entry| (PythonSource::Registry, entry.path))
})
.map_err(Error::from)
})
.into_iter()
.flatten_ok()
}
#[cfg(not(windows))]
{
Vec::new()
}
})
.flatten();

Expand Down Expand Up @@ -626,11 +645,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 Expand Up @@ -1293,7 +1307,7 @@ impl PythonPreference {
// If not dealing with a system interpreter source, we don't care about the preference
if !matches!(
source,
PythonSource::Managed | PythonSource::SearchPath | PythonSource::PyLauncher
PythonSource::Managed | PythonSource::SearchPath | PythonSource::Registry
) {
return true;
}
Expand All @@ -1302,10 +1316,10 @@ impl PythonPreference {
PythonPreference::OnlyManaged => matches!(source, PythonSource::Managed),
Self::Managed | Self::System => matches!(
source,
PythonSource::Managed | PythonSource::SearchPath | PythonSource::PyLauncher
PythonSource::Managed | PythonSource::SearchPath | PythonSource::Registry
),
PythonPreference::OnlySystem => {
matches!(source, PythonSource::SearchPath | PythonSource::PyLauncher)
matches!(source, PythonSource::SearchPath | PythonSource::Registry)
}
}
}
Expand Down Expand Up @@ -1616,7 +1630,7 @@ impl fmt::Display for PythonSource {
Self::CondaPrefix => f.write_str("conda prefix"),
Self::DiscoveredEnvironment => f.write_str("virtual environment"),
Self::SearchPath => f.write_str("search path"),
Self::PyLauncher => f.write_str("`py` launcher output"),
Self::Registry => f.write_str("registry"),
Self::Managed => f.write_str("managed installations"),
Self::ParentInterpreter => f.write_str("parent interpreter"),
}
Expand Down
4 changes: 1 addition & 3 deletions crates/uv-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub mod managed;
pub mod platform;
mod pointer_size;
mod prefix;
#[cfg(windows)]
mod py_launcher;
mod python_version;
mod target;
Expand Down Expand Up @@ -60,9 +61,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
171 changes: 93 additions & 78 deletions crates/uv-python/src/py_launcher.rs
Original file line number Diff line number Diff line change
@@ -1,93 +1,108 @@
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.clone()).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 == "Registry" {
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 entries without version 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"Python interpreter in the registry is not executable: `Software\Python\{}\{}",
company, tag
);
return None;
};

// `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!(
"Skipping Python interpreter ({executable_path}) \
with invalid registry version {s}: {err}",
);
None
}
})
.collect())
});

Some(RegistryPython {
path: PathBuf::from(executable_path),
version,
})
}
4 changes: 2 additions & 2 deletions crates/uv-settings/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,8 +591,8 @@ pub struct PipOptions {
/// workflows.
///
/// Supported formats:
/// - `3.10` looks for an installed Python 3.10 using `py --list-paths` on Windows, or
/// `python3.10` on Linux and macOS.
/// - `3.10` looks for an installed Python 3.10 in the registry on Windows (see
/// `py --list-paths`), or `python3.10` on Linux and macOS.
/// - `python3.10` or `python.exe` looks for a binary with the given name in `PATH`.
/// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path.
#[option(
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -2328,8 +2328,8 @@ which is intended for use in continuous integration (CI) environments or other a
workflows.

Supported formats:
- `3.10` looks for an installed Python 3.10 using `py --list-paths` on Windows, or
`python3.10` on Linux and macOS.
- `3.10` looks for an installed Python 3.10 in the registry on Windows (see
`py --list-paths`), or `python3.10` on Linux and macOS.
- `python3.10` or `python.exe` looks for a binary with the given name in `PATH`.
- `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path.

Expand Down
2 changes: 1 addition & 1 deletion uv.schema.json

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

Loading