diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e25a544bd07d..cda28b148f19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1733,6 +1733,24 @@ jobs: - name: "Validate global Python install" run: py -3.13 ./scripts/check_system_python.py --uv ./uv.exe + # Test our PEP 514 integration that installs Python into the Windows registry. + system-test-windows-registry: + timeout-minutes: 10 + needs: build-binary-windows-x86_64 + name: "check system | windows registry" + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: "Download binary" + uses: actions/download-artifact@v4 + with: + name: uv-windows-x86_64-${{ github.sha }} + + # NB: Run this last, we are modifying the registry + - name: "Test PEP 514 registration" + run: python ./scripts/check_registry.py --uv ./uv.exe + system-test-choco: timeout-minutes: 10 needs: build-binary-windows-x86_64 diff --git a/crates/uv-python/Cargo.toml b/crates/uv-python/Cargo.toml index fcea7d5ecb60..7b9b98d6b6fb 100644 --- a/crates/uv-python/Cargo.toml +++ b/crates/uv-python/Cargo.toml @@ -64,9 +64,9 @@ which = { workspace = true } procfs = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { workspace = true } windows-registry = { workspace = true } windows-result = { workspace = true } +windows-sys = { workspace = true } [dev-dependencies] anyhow = { version = "1.0.89" } diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index b4c35095c9bd..262e00311cce 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -24,13 +24,13 @@ use crate::interpreter::{StatusCodeError, UnexpectedResponseError}; use crate::managed::ManagedPythonInstallations; #[cfg(windows)] use crate::microsoft_store::find_microsoft_store_pythons; -#[cfg(windows)] -use crate::py_launcher::{registry_pythons, WindowsPython}; use crate::virtualenv::Error as VirtualEnvError; use crate::virtualenv::{ conda_environment_from_env, virtualenv_from_env, virtualenv_from_working_dir, virtualenv_python_executable, CondaEnvironmentKind, }; +#[cfg(windows)] +use crate::windows_registry::{registry_pythons, WindowsPython}; use crate::{Interpreter, PythonVersion}; /// A request to find a Python installation. @@ -324,7 +324,7 @@ fn python_executables_from_installed<'a>( } }) .inspect(|installation| debug!("Found managed installation `{installation}`")) - .map(|installation| (PythonSource::Managed, installation.executable()))) + .map(|installation| (PythonSource::Managed, installation.executable(false)))) }) }) .flatten_ok(); diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index b607149f66cc..54b20d6c8790 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -453,7 +453,7 @@ impl ManagedPythonDownload { .filter(|download| download.key.libc != Libc::Some(target_lexicon::Environment::Musl)) } - pub fn url(&self) -> &str { + pub fn url(&self) -> &'static str { self.url } @@ -465,7 +465,7 @@ impl ManagedPythonDownload { self.key.os() } - pub fn sha256(&self) -> Option<&str> { + pub fn sha256(&self) -> Option<&'static str> { self.sha256 } diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index 12a32b0c6c09..4e9f8eac3ea3 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -161,7 +161,7 @@ impl PythonInstallation { DownloadResult::Fetched(path) => path, }; - let installed = ManagedPythonInstallation::new(path)?; + let installed = ManagedPythonInstallation::new(path, download); installed.ensure_externally_managed()?; installed.ensure_sysconfig_patched()?; installed.ensure_canonical_executables()?; @@ -171,7 +171,7 @@ impl PythonInstallation { Ok(Self { source: PythonSource::Managed, - interpreter: Interpreter::query(installed.executable(), cache)?, + interpreter: Interpreter::query(installed.executable(false), cache)?, }) } @@ -282,7 +282,7 @@ impl PythonInstallationKey { } } - pub fn new_from_version( + fn new_from_version( implementation: LenientImplementationName, version: &PythonVersion, os: Os, @@ -320,6 +320,11 @@ impl PythonInstallationKey { .expect("Python installation keys must have valid Python versions") } + /// The version in `x.y.z` format. + pub fn sys_version(&self) -> String { + format!("{}.{}.{}", self.major, self.minor, self.patch) + } + pub fn arch(&self) -> &Arch { &self.arch } diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index 114415043371..48f661ab196f 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -37,13 +37,18 @@ mod microsoft_store; pub mod platform; mod pointer_size; mod prefix; -#[cfg(windows)] -mod py_launcher; mod python_version; mod sysconfig; mod target; mod version_files; mod virtualenv; +#[cfg(windows)] +pub mod windows_registry; + +#[cfg(windows)] +pub(crate) const COMPANY_KEY: &str = "Astral"; +#[cfg(windows)] +pub(crate) const COMPANY_DISPLAY_NAME: &str = "Astral Software Inc."; #[cfg(not(test))] pub(crate) fn current_dir() -> Result<std::path::PathBuf, std::io::Error> { diff --git a/crates/uv-python/src/macos_dylib.rs b/crates/uv-python/src/macos_dylib.rs index 7294497b3c9c..a77284da0c1a 100644 --- a/crates/uv-python/src/macos_dylib.rs +++ b/crates/uv-python/src/macos_dylib.rs @@ -56,7 +56,7 @@ impl Error { }; warn_user!( "Failed to patch the install name of the dynamic library for {}. This may cause issues when building Python native extensions.{}", - installation.executable().simplified_display(), + installation.executable(false).simplified_display(), error ); } diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 1efafa64850e..885f92cb1401 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -16,7 +16,7 @@ use uv_state::{StateBucket, StateStore}; use uv_static::EnvVars; use uv_trampoline_builder::{windows_python_launcher, Launcher}; -use crate::downloads::Error as DownloadError; +use crate::downloads::{Error as DownloadError, ManagedPythonDownload}; use crate::implementation::{ Error as ImplementationError, ImplementationName, LenientImplementationName, }; @@ -229,7 +229,7 @@ impl ManagedPythonInstallations { .unwrap_or(true) }) .filter_map(|path| { - ManagedPythonInstallation::new(path) + ManagedPythonInstallation::from_path(path) .inspect_err(|err| { warn!("Ignoring malformed managed Python entry:\n {err}"); }) @@ -294,10 +294,27 @@ pub struct ManagedPythonInstallation { path: PathBuf, /// An install key for the Python version. key: PythonInstallationKey, + /// The URL with the Python archive. + /// + /// Empty when self was constructed from a path. + url: Option<&'static str>, + /// The SHA256 of the Python archive at the URL. + /// + /// Empty when self was constructed from a path. + sha256: Option<&'static str>, } impl ManagedPythonInstallation { - pub fn new(path: PathBuf) -> Result<Self, Error> { + pub fn new(path: PathBuf, download: &ManagedPythonDownload) -> Self { + Self { + path, + key: download.key().clone(), + url: Some(download.url()), + sha256: download.sha256(), + } + } + + pub(crate) fn from_path(path: PathBuf) -> Result<Self, Error> { let key = PythonInstallationKey::from_str( path.file_name() .ok_or(Error::NameError("name is empty".to_string()))? @@ -307,7 +324,12 @@ impl ManagedPythonInstallation { let path = std::path::absolute(&path).map_err(|err| Error::AbsolutePath(path, err))?; - Ok(Self { path, key }) + Ok(Self { + path, + key, + url: None, + sha256: None, + }) } /// The path to this managed installation's Python executable. @@ -315,7 +337,10 @@ impl ManagedPythonInstallation { /// If the installation has multiple execututables i.e., `python`, `python3`, etc., this will /// return the _canonical_ executable name which the other names link to. On Unix, this is /// `python{major}.{minor}{variant}` and on Windows, this is `python{exe}`. - pub fn executable(&self) -> PathBuf { + /// + /// If windowed is true, `pythonw.exe` is selected over `python.exe` on windows, with no changes + /// on non-windows. + pub fn executable(&self, windowed: bool) -> PathBuf { let implementation = match self.implementation() { ImplementationName::CPython => "python", ImplementationName::PyPy => "pypy", @@ -342,6 +367,9 @@ impl ManagedPythonInstallation { // On Windows, the executable is just `python.exe` even for alternative variants let variant = if cfg!(unix) { self.key.variant.suffix() + } else if cfg!(windows) && windowed { + // Use windowed Python that doesn't open a terminal. + "w" } else { "" }; @@ -412,11 +440,11 @@ impl ManagedPythonInstallation { pub fn satisfies(&self, request: &PythonRequest) -> bool { match request { - PythonRequest::File(path) => self.executable() == *path, + PythonRequest::File(path) => self.executable(false) == *path, PythonRequest::Default | PythonRequest::Any => true, PythonRequest::Directory(path) => self.path() == *path, PythonRequest::ExecutableName(name) => self - .executable() + .executable(false) .file_name() .is_some_and(|filename| filename.to_string_lossy() == *name), PythonRequest::Implementation(implementation) => { @@ -432,7 +460,7 @@ impl ManagedPythonInstallation { /// Ensure the environment contains the canonical Python executable names. pub fn ensure_canonical_executables(&self) -> Result<(), Error> { - let python = self.executable(); + let python = self.executable(false); let canonical_names = &["python"]; @@ -539,7 +567,7 @@ impl ManagedPythonInstallation { /// /// If the file already exists at the target path, an error will be returned. pub fn create_bin_link(&self, target: &Path) -> Result<(), Error> { - let python = self.executable(); + let python = self.executable(false); let bin = target.parent().ok_or(Error::NoExecutableDirectory)?; fs_err::create_dir_all(bin).map_err(|err| Error::ExecutableDirectory { @@ -585,7 +613,7 @@ impl ManagedPythonInstallation { /// [`ManagedPythonInstallation::create_bin_link`]. pub fn is_bin_link(&self, path: &Path) -> bool { if cfg!(unix) { - is_same_file(path, self.executable()).unwrap_or_default() + is_same_file(path, self.executable(false)).unwrap_or_default() } else if cfg!(windows) { let Some(launcher) = Launcher::try_from_path(path).unwrap_or_default() else { return false; @@ -593,7 +621,7 @@ impl ManagedPythonInstallation { if !matches!(launcher.kind, uv_trampoline_builder::LauncherKind::Python) { return false; } - launcher.python_path == self.executable() + launcher.python_path == self.executable(false) } else { unreachable!("Only Windows and Unix are supported") } @@ -627,6 +655,14 @@ impl ManagedPythonInstallation { // Do not upgrade if the patch versions are the same self.key.patch != other.key.patch } + + pub fn url(&self) -> Option<&'static str> { + self.url + } + + pub fn sha256(&self) -> Option<&'static str> { + self.sha256 + } } /// Generate a platform portion of a key from the environment. diff --git a/crates/uv-python/src/microsoft_store.rs b/crates/uv-python/src/microsoft_store.rs index c0226733c03d..3709f54f17e2 100644 --- a/crates/uv-python/src/microsoft_store.rs +++ b/crates/uv-python/src/microsoft_store.rs @@ -3,7 +3,7 @@ //! //! Effectively a port of <https://github.com/python/cpython/blob/58ce131037ecb34d506a613f21993cde2056f628/PC/launcher2.c#L1744> -use crate::py_launcher::WindowsPython; +use crate::windows_registry::WindowsPython; use crate::PythonVersion; use itertools::Either; use std::env; diff --git a/crates/uv-python/src/platform.rs b/crates/uv-python/src/platform.rs index ec6abfeaa72d..7ebc892c816e 100644 --- a/crates/uv-python/src/platform.rs +++ b/crates/uv-python/src/platform.rs @@ -102,6 +102,10 @@ impl Arch { variant: None, } } + + pub fn family(&self) -> target_lexicon::Architecture { + self.family + } } impl Display for Libc { diff --git a/crates/uv-python/src/py_launcher.rs b/crates/uv-python/src/py_launcher.rs deleted file mode 100644 index af7482d19def..000000000000 --- a/crates/uv-python/src/py_launcher.rs +++ /dev/null @@ -1,98 +0,0 @@ -use crate::PythonVersion; -use std::cmp::Ordering; -use std::path::PathBuf; -use std::str::FromStr; -use tracing::debug; -use windows_registry::{Key, CURRENT_USER, LOCAL_MACHINE}; - -/// A Python interpreter found in the Windows registry through PEP 514 or from a known Microsoft -/// Store path. -/// -/// 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 WindowsPython { - pub(crate) path: PathBuf, - pub(crate) version: Option<PythonVersion>, -} - -/// Find all Pythons registered in the Windows registry following PEP 514. -pub(crate) fn registry_pythons() -> Result<Vec<WindowsPython>, 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)?; - - if let Some(registry_python) = read_registry_entry(&company, &tag, &tag_key) { - registry_pythons.push(registry_python); - } - } - } - } - - // The registry has no natural ordering, so we're processing the latest version first. - registry_pythons.sort_by(|a, b| { - match (&a.version, &b.version) { - // Place entries with a version before those without a version. - (Some(_), None) => Ordering::Greater, - (None, Some(_)) => Ordering::Less, - // We want the highest version on top, which is the inverse from the regular order. The - // path is an arbitrary but stable tie-breaker. - (Some(version_a), Some(version_b)) => { - version_a.cmp(version_b).reverse().then(a.path.cmp(&b.path)) - } - // Sort the entries without a version arbitrarily, but stable (by path). - (None, None) => a.path.cmp(&b.path), - } - }); - - Ok(registry_pythons) -} - -fn read_registry_entry(company: &str, tag: &str, tag_key: &Key) -> Option<WindowsPython> { - // `ExecutablePath` is mandatory for executable Pythons. - let Ok(executable_path) = tag_key - .open("InstallPath") - .and_then(|install_path| install_path.get_value("ExecutablePath")) - .and_then(String::try_from) - 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") - .and_then(String::try_from) - .ok() - .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 - } - }); - - Some(WindowsPython { - path: PathBuf::from(executable_path), - version, - }) -} diff --git a/crates/uv-python/src/windows_registry.rs b/crates/uv-python/src/windows_registry.rs new file mode 100644 index 000000000000..67d1f784a953 --- /dev/null +++ b/crates/uv-python/src/windows_registry.rs @@ -0,0 +1,275 @@ +//! PEP 514 interactions with the Windows registry. + +use crate::managed::ManagedPythonInstallation; +use crate::platform::Arch; +use crate::{PythonInstallationKey, PythonVersion, COMPANY_DISPLAY_NAME, COMPANY_KEY}; +use std::cmp::Ordering; +use std::collections::HashSet; +use std::path::PathBuf; +use std::str::FromStr; +use target_lexicon::PointerWidth; +use thiserror::Error; +use tracing::debug; +use uv_warnings::{warn_user, warn_user_once}; +use windows_registry::{Key, Value, CURRENT_USER, HSTRING, LOCAL_MACHINE}; +use windows_result::HRESULT; +use windows_sys::Win32::Foundation::ERROR_FILE_NOT_FOUND; + +/// Code returned when the registry key doesn't exist. +const ERROR_NOT_FOUND: HRESULT = HRESULT::from_win32(ERROR_FILE_NOT_FOUND); + +/// A Python interpreter found in the Windows registry through PEP 514 or from a known Microsoft +/// Store path. +/// +/// 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 WindowsPython { + pub(crate) path: PathBuf, + pub(crate) version: Option<PythonVersion>, +} + +/// Find all Pythons registered in the Windows registry following PEP 514. +pub(crate) fn registry_pythons() -> Result<Vec<WindowsPython>, windows_result::Error> { + let mut registry_pythons = Vec::new(); + // Prefer `HKEY_CURRENT_USER` over `HKEY_LOCAL_MACHINE` + 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)?; + + if let Some(registry_python) = read_registry_entry(&company, &tag, &tag_key) { + registry_pythons.push(registry_python); + } + } + } + } + + // The registry has no natural ordering, so we're processing the latest version first. + registry_pythons.sort_by(|a, b| { + match (&a.version, &b.version) { + // Place entries with a version before those without a version. + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + // We want the highest version on top, which is the inverse from the regular order. The + // path is an arbitrary but stable tie-breaker. + (Some(version_a), Some(version_b)) => { + version_a.cmp(version_b).reverse().then(a.path.cmp(&b.path)) + } + // Sort the entries without a version arbitrarily, but stable (by path). + (None, None) => a.path.cmp(&b.path), + } + }); + + Ok(registry_pythons) +} + +fn read_registry_entry(company: &str, tag: &str, tag_key: &Key) -> Option<WindowsPython> { + // `ExecutablePath` is mandatory for executable Pythons. + let Ok(executable_path) = tag_key + .open("InstallPath") + .and_then(|install_path| install_path.get_value("ExecutablePath")) + .and_then(String::try_from) + 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") + .and_then(String::try_from) + .ok() + .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 + } + }); + + Some(WindowsPython { + path: PathBuf::from(executable_path), + version, + }) +} + +#[derive(Debug, Error)] +pub enum ManagedPep514Error { + #[error("Windows has an unknown pointer width for arch: `{_0}`")] + InvalidPointerSize(Arch), +} + +/// Register a managed Python installation in the Windows registry following PEP 514. +pub fn create_registry_entry( + installation: &ManagedPythonInstallation, + errors: &mut Vec<(PythonInstallationKey, anyhow::Error)>, +) -> Result<(), ManagedPep514Error> { + let pointer_width = match installation.key().arch().family().pointer_width() { + Ok(PointerWidth::U32) => 32, + Ok(PointerWidth::U64) => 64, + _ => { + return Err(ManagedPep514Error::InvalidPointerSize( + *installation.key().arch(), + )); + } + }; + + if let Err(err) = write_registry_entry(installation, pointer_width) { + errors.push((installation.key().clone(), err.into())); + } + + Ok(()) +} + +fn write_registry_entry( + installation: &ManagedPythonInstallation, + pointer_width: i32, +) -> windows_registry::Result<()> { + // We currently just overwrite all known keys, without removing prior entries first + + // Similar to using the bin directory in HOME on Unix, we only install for the current user + // on Windows. + let company = CURRENT_USER.create(format!("Software\\Python\\{COMPANY_KEY}"))?; + company.set_string("DisplayName", COMPANY_DISPLAY_NAME)?; + company.set_string("SupportUrl", "https://github.com/astral-sh/uv")?; + + // Ex) CPython3.13.1 + let tag = company.create(registry_python_tag(installation.key()))?; + let display_name = format!( + "{} {} ({}-bit)", + installation.key().implementation().pretty(), + installation.key().version(), + pointer_width + ); + tag.set_string("DisplayName", &display_name)?; + tag.set_string("SupportUrl", "https://github.com/astral-sh/uv")?; + tag.set_string("Version", &installation.key().version().to_string())?; + tag.set_string("SysVersion", &installation.key().sys_version())?; + tag.set_string("SysArchitecture", &format!("{pointer_width}bit"))?; + // Store `python-build-standalone` release + if let Some(url) = installation.url() { + tag.set_string("DownloadUrl", url)?; + } + if let Some(sha256) = installation.sha256() { + tag.set_string("DownloadSha256", sha256)?; + } + + let install_path = tag.create("InstallPath")?; + install_path.set_value( + "", + &Value::from(&HSTRING::from(installation.path().as_os_str())), + )?; + install_path.set_value( + "ExecutablePath", + &Value::from(&HSTRING::from(installation.executable(false).as_os_str())), + )?; + install_path.set_value( + "WindowedExecutablePath", + &Value::from(&HSTRING::from(installation.executable(true).as_os_str())), + )?; + Ok(()) +} + +fn registry_python_tag(key: &PythonInstallationKey) -> String { + format!("{}{}", key.implementation().pretty(), key.version()) +} + +/// Remove requested Python entries from the Windows Registry (PEP 514). +pub fn remove_registry_entry<'a>( + installations: impl IntoIterator<Item = &'a ManagedPythonInstallation>, + all: bool, + errors: &mut Vec<(PythonInstallationKey, anyhow::Error)>, +) { + let astral_key = format!("Software\\Python\\{COMPANY_KEY}"); + if all { + debug!("Removing registry key HKCU:\\{}", astral_key); + if let Err(err) = CURRENT_USER.remove_tree(&astral_key) { + if err.code() == ERROR_NOT_FOUND { + debug!("No registry entries to remove, no registry key {astral_key}"); + } else { + warn_user!("Failed to clear registry entries under {astral_key}: {err}"); + } + } + return; + } + + for installation in installations { + let python_tag = registry_python_tag(installation.key()); + let python_entry = format!("{astral_key}\\{python_tag}"); + debug!("Removing registry key HKCU:\\{}", python_entry); + if let Err(err) = CURRENT_USER.remove_tree(&python_entry) { + if err.code() == ERROR_NOT_FOUND { + debug!( + "No registry entries to remove for {}, no registry key {}", + installation.key(), + python_entry + ); + } else { + errors.push(( + installation.key().clone(), + anyhow::Error::new(err) + .context("Failed to clear registry entries under HKCU:\\{python_entry}"), + )); + } + }; + } +} + +/// Remove Python entries from the Windows Registry (PEP 514) that are not matching any +/// installation. +pub fn remove_orphan_registry_entries(installations: &[ManagedPythonInstallation]) { + let keep: HashSet<_> = installations + .iter() + .map(|installation| registry_python_tag(installation.key())) + .collect(); + let astral_key = format!("Software\\Python\\{COMPANY_KEY}"); + let key = match CURRENT_USER.open(&astral_key) { + Ok(subkeys) => subkeys, + Err(err) if err.code() == ERROR_NOT_FOUND => { + return; + } + Err(err) => { + // TODO(konsti): We don't have an installation key here. + warn_user_once!("Failed to open HKCU:\\{astral_key}: {err}"); + return; + } + }; + // Separate assignment since `keys()` creates a borrow. + let subkeys = match key.keys() { + Ok(subkeys) => subkeys, + Err(err) => { + // TODO(konsti): We don't have an installation key here. + warn_user_once!("Failed to list subkeys of HKCU:\\{astral_key}: {err}"); + return; + } + }; + for subkey in subkeys { + if keep.contains(&subkey) { + continue; + } + let python_entry = format!("{astral_key}\\{subkey}"); + debug!("Removing orphan registry key HKCU:\\{}", python_entry); + if let Err(err) = CURRENT_USER.remove_tree(&python_entry) { + // TODO(konsti): We don't have an installation key here. + warn_user_once!("Failed to remove orphan registry key HKCU:\\{python_entry}: {err}"); + }; + } +} diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 229fd9bb631e..69aa4a5d879e 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -2,7 +2,7 @@ use std::fmt::Write; use std::io::ErrorKind; use std::path::{Path, PathBuf}; -use anyhow::Result; +use anyhow::{Error, Result}; use futures::stream::FuturesUnordered; use futures::StreamExt; use itertools::{Either, Itertools}; @@ -258,7 +258,7 @@ pub(crate) async fn install( for download in &downloads { tasks.push(async { ( - download.key(), + *download, download .fetch_with_retry( &client, @@ -276,16 +276,16 @@ pub(crate) async fn install( let mut errors = vec![]; let mut downloaded = Vec::with_capacity(downloads.len()); - while let Some((key, result)) = tasks.next().await { + while let Some((download, result)) = tasks.next().await { match result { - Ok(download) => { - let path = match download { + Ok(download_result) => { + let path = match download_result { // We should only encounter already-available during concurrent installs DownloadResult::AlreadyAvailable(path) => path, DownloadResult::Fetched(path) => path, }; - let installation = ManagedPythonInstallation::new(path)?; + let installation = ManagedPythonInstallation::new(path, download); changelog.installed.insert(installation.key().clone()); if changelog.existing.contains(installation.key()) { changelog.uninstalled.insert(installation.key().clone()); @@ -293,7 +293,7 @@ pub(crate) async fn install( downloaded.push(installation); } Err(err) => { - errors.push((key, anyhow::Error::new(err))); + errors.push((download.key().clone(), anyhow::Error::new(err))); } } } @@ -326,172 +326,24 @@ pub(crate) async fn install( .expect("We should have a bin directory with preview enabled") .as_path(); - let targets = if (default || is_default_install) - && first_request.matches_installation(installation) - { - vec![ - installation.key().executable_name_minor(), - installation.key().executable_name_major(), - installation.key().executable_name(), - ] - } else { - vec![installation.key().executable_name_minor()] - }; - - for target in targets { - let target = bin.join(target); - match installation.create_bin_link(&target) { - Ok(()) => { - debug!( - "Installed executable at `{}` for {}", - target.simplified_display(), - installation.key(), - ); - changelog.installed.insert(installation.key().clone()); - changelog - .installed_executables - .entry(installation.key().clone()) - .or_default() - .insert(target.clone()); - } - Err(uv_python::managed::Error::LinkExecutable { from: _, to, err }) - if err.kind() == ErrorKind::AlreadyExists => - { - debug!( - "Inspecting existing executable at `{}`", - target.simplified_display() - ); - - // Figure out what installation it references, if any - let existing = find_matching_bin_link( - installations - .iter() - .copied() - .chain(existing_installations.iter()), - &target, - ); - - match existing { - None => { - // Determine if the link is valid, i.e., if it points to an existing - // Python we don't manage. On Windows, we just assume it is valid because - // symlinks are not common for Python interpreters. - let valid_link = cfg!(windows) - || target - .read_link() - .and_then(|target| target.try_exists()) - .inspect_err(|err| { - debug!("Failed to inspect executable with error: {err}"); - }) - // If we can't verify the link, assume it is valid. - .unwrap_or(true); - - // There's an existing executable we don't manage, require `--force` - if valid_link { - if !force { - errors.push(( - installation.key(), - anyhow::anyhow!( - "Executable already exists at `{}` but is not managed by uv; use `--force` to replace it", - to.simplified_display() - ), - )); - continue; - } - debug!( - "Replacing existing executable at `{}` due to `--force`", - target.simplified_display() - ); - } else { - debug!( - "Replacing broken symlink at `{}`", - target.simplified_display() - ); - } - } - Some(existing) if existing == *installation => { - // The existing link points to the same installation, so we're done unless - // they requested we reinstall - if !(reinstall || force) { - debug!( - "Executable at `{}` is already for `{}`", - target.simplified_display(), - installation.key(), - ); - continue; - } - debug!( - "Replacing existing executable for `{}` at `{}`", - installation.key(), - target.simplified_display(), - ); - } - Some(existing) => { - // The existing link points to a different installation, check if it - // is reasonable to replace - if force { - debug!( - "Replacing existing executable for `{}` at `{}` with executable for `{}` due to `--force` flag", - existing.key(), - target.simplified_display(), - installation.key(), - ); - } else { - if installation.is_upgrade_of(existing) { - debug!( - "Replacing existing executable for `{}` at `{}` with executable for `{}` since it is an upgrade", - existing.key(), - target.simplified_display(), - installation.key(), - ); - } else if default { - debug!( - "Replacing existing executable for `{}` at `{}` with executable for `{}` since `--default` was requested`", - existing.key(), - target.simplified_display(), - installation.key(), - ); - } else { - debug!( - "Executable already exists for `{}` at `{}`. Use `--force` to replace it", - existing.key(), - to.simplified_display() - ); - continue; - } - } - } - } - - // Replace the existing link - fs_err::remove_file(&to)?; - - if let Some(existing) = existing { - // Ensure we do not report installation of this executable for an existing - // key if we undo it - changelog - .installed_executables - .entry(existing.key().clone()) - .or_default() - .remove(&target); - } + create_bin_links( + installation, + bin, + reinstall, + force, + default, + is_default_install, + first_request, + &existing_installations, + &installations, + &mut changelog, + &mut errors, + )?; - installation.create_bin_link(&target)?; - debug!( - "Updated executable at `{}` to {}", - target.simplified_display(), - installation.key(), - ); - changelog.installed.insert(installation.key().clone()); - changelog - .installed_executables - .entry(installation.key().clone()) - .or_default() - .insert(target.clone()); - } - Err(err) => { - errors.push((installation.key(), anyhow::Error::new(err))); - } + if preview.is_enabled() { + #[cfg(windows)] + { + uv_python::windows_registry::create_registry_entry(installation, &mut errors)?; } } } @@ -601,6 +453,191 @@ pub(crate) async fn install( Ok(ExitStatus::Success) } +/// Link the binaries of a managed Python installation to the bin directory. +#[allow(clippy::fn_params_excessive_bools)] +fn create_bin_links( + installation: &ManagedPythonInstallation, + bin: &Path, + reinstall: bool, + force: bool, + default: bool, + is_default_install: bool, + first_request: &InstallRequest, + existing_installations: &[ManagedPythonInstallation], + installations: &[&ManagedPythonInstallation], + changelog: &mut Changelog, + errors: &mut Vec<(PythonInstallationKey, Error)>, +) -> Result<(), Error> { + let targets = + if (default || is_default_install) && first_request.matches_installation(installation) { + vec![ + installation.key().executable_name_minor(), + installation.key().executable_name_major(), + installation.key().executable_name(), + ] + } else { + vec![installation.key().executable_name_minor()] + }; + + for target in targets { + let target = bin.join(target); + match installation.create_bin_link(&target) { + Ok(()) => { + debug!( + "Installed executable at `{}` for {}", + target.simplified_display(), + installation.key(), + ); + changelog.installed.insert(installation.key().clone()); + changelog + .installed_executables + .entry(installation.key().clone()) + .or_default() + .insert(target.clone()); + } + Err(uv_python::managed::Error::LinkExecutable { from: _, to, err }) + if err.kind() == ErrorKind::AlreadyExists => + { + debug!( + "Inspecting existing executable at `{}`", + target.simplified_display() + ); + + // Figure out what installation it references, if any + let existing = find_matching_bin_link( + installations + .iter() + .copied() + .chain(existing_installations.iter()), + &target, + ); + + match existing { + None => { + // Determine if the link is valid, i.e., if it points to an existing + // Python we don't manage. On Windows, we just assume it is valid because + // symlinks are not common for Python interpreters. + let valid_link = cfg!(windows) + || target + .read_link() + .and_then(|target| target.try_exists()) + .inspect_err(|err| { + debug!("Failed to inspect executable with error: {err}"); + }) + // If we can't verify the link, assume it is valid. + .unwrap_or(true); + + // There's an existing executable we don't manage, require `--force` + if valid_link { + if !force { + errors.push(( + installation.key().clone(), + anyhow::anyhow!( + "Executable already exists at `{}` but is not managed by uv; use `--force` to replace it", + to.simplified_display() + ), + )); + continue; + } + debug!( + "Replacing existing executable at `{}` due to `--force`", + target.simplified_display() + ); + } else { + debug!( + "Replacing broken symlink at `{}`", + target.simplified_display() + ); + } + } + Some(existing) if existing == installation => { + // The existing link points to the same installation, so we're done unless + // they requested we reinstall + if !(reinstall || force) { + debug!( + "Executable at `{}` is already for `{}`", + target.simplified_display(), + installation.key(), + ); + continue; + } + debug!( + "Replacing existing executable for `{}` at `{}`", + installation.key(), + target.simplified_display(), + ); + } + Some(existing) => { + // The existing link points to a different installation, check if it + // is reasonable to replace + if force { + debug!( + "Replacing existing executable for `{}` at `{}` with executable for `{}` due to `--force` flag", + existing.key(), + target.simplified_display(), + installation.key(), + ); + } else { + if installation.is_upgrade_of(existing) { + debug!( + "Replacing existing executable for `{}` at `{}` with executable for `{}` since it is an upgrade", + existing.key(), + target.simplified_display(), + installation.key(), + ); + } else if default { + debug!( + "Replacing existing executable for `{}` at `{}` with executable for `{}` since `--default` was requested`", + existing.key(), + target.simplified_display(), + installation.key(), + ); + } else { + debug!( + "Executable already exists for `{}` at `{}`. Use `--force` to replace it", + existing.key(), + to.simplified_display() + ); + continue; + } + } + } + } + + // Replace the existing link + fs_err::remove_file(&to)?; + + if let Some(existing) = existing { + // Ensure we do not report installation of this executable for an existing + // key if we undo it + changelog + .installed_executables + .entry(existing.key().clone()) + .or_default() + .remove(&target); + } + + installation.create_bin_link(&target)?; + debug!( + "Updated executable at `{}` to {}", + target.simplified_display(), + installation.key(), + ); + changelog.installed.insert(installation.key().clone()); + changelog + .installed_executables + .entry(installation.key().clone()) + .or_default() + .insert(target.clone()); + } + Err(err) => { + errors.push((installation.key().clone(), anyhow::Error::new(err))); + } + } + } + Ok(()) +} + pub(crate) fn format_executables( event: &ChangeEvent, executables: &FxHashMap<PythonInstallationKey, FxHashSet<PathBuf>>, @@ -681,5 +718,5 @@ fn find_matching_bin_link<'a>( unreachable!("Only Windows and Unix are supported") }; - installations.find(|installation| installation.executable() == target) + installations.find(|installation| installation.executable(false) == target) } diff --git a/crates/uv/src/commands/python/uninstall.rs b/crates/uv/src/commands/python/uninstall.rs index 12556382ec74..e506a47ace6b 100644 --- a/crates/uv/src/commands/python/uninstall.rs +++ b/crates/uv/src/commands/python/uninstall.rs @@ -7,9 +7,10 @@ use futures::stream::FuturesUnordered; use futures::StreamExt; use itertools::Itertools; use owo_colors::OwoColorize; - use rustc_hash::{FxHashMap, FxHashSet}; use tracing::{debug, warn}; + +use uv_configuration::PreviewMode; use uv_fs::Simplified; use uv_python::downloads::PythonDownloadRequest; use uv_python::managed::{python_executable_dir, ManagedPythonInstallations}; @@ -25,15 +26,15 @@ pub(crate) async fn uninstall( install_dir: Option<PathBuf>, targets: Vec<String>, all: bool, - printer: Printer, + preview: PreviewMode, ) -> Result<ExitStatus> { let installations = ManagedPythonInstallations::from_settings(install_dir)?.init()?; let _lock = installations.lock().await?; // Perform the uninstallation. - do_uninstall(&installations, targets, all, printer).await?; + do_uninstall(&installations, targets, all, printer, preview).await?; // Clean up any empty directories. if uv_fs::directories(installations.root()).all(|path| uv_fs::is_temporary(&path)) { @@ -62,6 +63,7 @@ async fn do_uninstall( targets: Vec<String>, all: bool, printer: Printer, + preview: PreviewMode, ) -> Result<ExitStatus> { let start = std::time::Instant::now(); @@ -107,6 +109,16 @@ async fn do_uninstall( matching_installations.insert(installation.clone()); } if !found { + // Clear any remnants in the registry + if preview.is_enabled() { + #[cfg(windows)] + { + uv_python::windows_registry::remove_orphan_registry_entries( + &installed_installations, + ); + } + } + if matches!(requests.as_slice(), [PythonRequest::Default]) { writeln!(printer.stderr(), "No Python installations found")?; return Ok(ExitStatus::Failure); @@ -190,12 +202,22 @@ async fn do_uninstall( let mut errors = vec![]; while let Some((key, result)) = tasks.next().await { if let Err(err) = result { - errors.push((key.clone(), err)); + errors.push((key.clone(), anyhow::Error::new(err))); } else { uninstalled.push(key.clone()); } } + #[cfg(windows)] + if preview.is_enabled() { + uv_python::windows_registry::remove_registry_entry( + &matching_installations, + all, + &mut errors, + ); + uv_python::windows_registry::remove_orphan_registry_entries(&installed_installations); + } + // Report on any uninstalled installations. if !uninstalled.is_empty() { if let [uninstalled] = uninstalled.as_slice() { diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 3af174ca7d29..94b4f9ee23bd 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1179,7 +1179,14 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> { let args = settings::PythonUninstallSettings::resolve(args, filesystem); show_settings!(args); - commands::python_uninstall(args.install_dir, args.targets, args.all, printer).await + commands::python_uninstall( + args.install_dir, + args.targets, + args.all, + printer, + globals.preview, + ) + .await } Commands::Python(PythonNamespace { command: PythonCommand::Find(args), diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 51f8670b2824..cef8a0746e5f 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -1077,7 +1077,7 @@ pub fn get_python(version: &PythonVersion) -> PathBuf { .expect("Tests are run on a supported platform") .next() .as_ref() - .map(uv_python::managed::ManagedPythonInstallation::executable) + .map(|python| python.executable(false)) }) // We'll search for the request Python on the PATH if not found in the python versions // We hack this into a `PathBuf` to satisfy the compiler but it's just a string diff --git a/scripts/check_registry.py b/scripts/check_registry.py new file mode 100644 index 000000000000..14bf144af75a --- /dev/null +++ b/scripts/check_registry.py @@ -0,0 +1,219 @@ +"""Check that adding uv's python-build-standalone distributions are successfully added +and removed from the Windows registry following PEP 514.""" + +import re +import subprocess +import sys +from argparse import ArgumentParser + +# We apply the same download URL/hash redaction to the actual output, too. We don't +# redact the path inside the runner, if the runner configuration changes +# (or uv's installation paths), please update the snapshots. +expected_registry = [ + # Our company key + r""" +Name Property +---- -------- +Astral DisplayName : Astral Software Inc. + SupportUrl : https://github.com/astral-sh/uv +""", + # The actual Python installations + r""" + Hive: HKEY_CURRENT_USER\Software\Python + + +Name Property +---- -------- +Astral DisplayName : Astral Software Inc. + SupportUrl : https://github.com/astral-sh/uv + + + Hive: HKEY_CURRENT_USER\Software\Python\Astral + + +Name Property +---- -------- +CPython3.11.11 DisplayName : CPython 3.11.11 (64-bit) + SupportUrl : https://github.com/astral-sh/uv + Version : 3.11.11 + SysVersion : 3.11.11 + SysArchitecture : 64bit + DownloadUrl : <downloadUrl> + DownloadSha256 : <downloadSha256> + + + Hive: HKEY_CURRENT_USER\Software\Python\Astral\CPython3.11.11 + + +Name Property +---- -------- +InstallPath (default) : C:\Users\runneradmin\AppData\Roaming\uv\python\cpython-3.11.11-windows-x86_64-none + ExecutablePath : C:\Users\runneradmin\AppData\Roaming\uv\python\cpython-3.11.11-windows-x86_64-none\python.exe + WindowedExecutablePath : C:\Users\runneradmin\AppData\Roaming\uv\python\cpython-3.11.11-windows-x86_64-none\pythonw.exe +""", + r""" + Hive: HKEY_CURRENT_USER\Software\Python\Astral + + +Name Property +---- -------- +CPython3.12.8 DisplayName : CPython 3.12.8 (64-bit) + SupportUrl : https://github.com/astral-sh/uv + Version : 3.12.8 + SysVersion : 3.12.8 + SysArchitecture : 64bit + DownloadUrl : <downloadUrl> + DownloadSha256 : <downloadSha256> + + + Hive: HKEY_CURRENT_USER\Software\Python\Astral\CPython3.12.8 + + +Name Property +---- -------- +InstallPath (default) : C:\Users\runneradmin\AppData\Roaming\uv\python\cpython-3.12.8-windows-x86_64-none + ExecutablePath : C:\Users\runneradmin\AppData\Roaming\uv\python\cpython-3.12.8-windows-x86_64-none\python.exe + WindowedExecutablePath : C:\Users\runneradmin\AppData\Roaming\uv\python\cpython-3.12.8-windows-x86_64-none\pythonw.exe +""", + r""" + Hive: HKEY_CURRENT_USER\Software\Python\Astral + + +Name Property +---- -------- +CPython3.13.1 DisplayName : CPython 3.13.1 (64-bit) + SupportUrl : https://github.com/astral-sh/uv + Version : 3.13.1 + SysVersion : 3.13.1 + SysArchitecture : 64bit + DownloadUrl : <downloadUrl> + DownloadSha256 : <downloadSha256> + + + Hive: HKEY_CURRENT_USER\Software\Python\Astral\CPython3.13.1 + + +Name Property +---- -------- +InstallPath (default) : C:\Users\runneradmin\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none + ExecutablePath : C:\Users\runneradmin\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\python.exe + WindowedExecutablePath : C:\Users\runneradmin\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\pythonw.exe +""", +] + + +def filter_snapshot(snapshot: str) -> str: + # Trim only newlines, there's leading whitespace before the `Hive:` entry + snapshot = snapshot.strip("\n\r") + # Trim trailing whitespace, Windows pads lines up to length + snapshot = "\n".join(line.rstrip() for line in snapshot.splitlines()) + # Long URLs can be wrapped into multiple lines + snapshot = re.sub( + "DownloadUrl ( *): .*(\n.*)+?(\n +)DownloadSha256", + r"DownloadUrl \1: <downloadUrl>\3DownloadSha256", + snapshot, + ) + snapshot = re.sub( + "DownloadSha256 ( *): .*", r"DownloadSha256 \1: <downloadSha256>", snapshot + ) + return snapshot + + +def main(uv: str): + # `py --list-paths` output + py_311_line = r" -V:Astral/CPython3.11.11 C:\Users\runneradmin\AppData\Roaming\uv\python\cpython-3.11.11-windows-x86_64-none\python.exe" + py_312_line = r" -V:Astral/CPython3.12.8 C:\Users\runneradmin\AppData\Roaming\uv\python\cpython-3.12.8-windows-x86_64-none\python.exe" + py_313_line = r" -V:Astral/CPython3.13.1 C:\Users\runneradmin\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\python.exe" + + # Use the powershell command to get an outside view on the registry values we wrote + # By default, powershell wraps the output at terminal size + list_registry_command = r"Get-ChildItem -Path HKCU:\Software\Python -Recurse | Format-Table | Out-String -width 1000" + + # Check 1: Install interpreters and check that all their keys are set in the + # registry and that the Python launcher for Windows finds it. + # Check 1a: Install new interpreters. + # Check 1b: Request installation of already installed interpreters. + for _ in range(2): + print("Installing Python 3.11.11, 3.12.8, and 3.13.1") + subprocess.check_call([uv, "python", "install", "-v", "--preview", "3.11.11"]) + subprocess.check_call([uv, "python", "install", "-v", "--preview", "3.12.8"]) + subprocess.check_call([uv, "python", "install", "-v", "--preview", "3.13.1"]) + # The default shell for a subprocess is not powershell + actual_registry = subprocess.check_output( + ["powershell", "-Command", list_registry_command], text=True + ) + for expected in expected_registry: + if filter_snapshot(expected) not in filter_snapshot(actual_registry): + print("Registry mismatch:") + print("Expected Snippet:") + print("=" * 80) + print(filter_snapshot(expected)) + print("=" * 80) + print("Actual:") + print("=" * 80) + print(filter_snapshot(actual_registry)) + print("=" * 80) + sys.exit(1) + listed_interpreters = subprocess.check_output(["py", "--list-paths"], text=True) + py_listed = set(listed_interpreters.splitlines()) + if ( + py_311_line not in py_listed + or py_312_line not in py_listed + or py_313_line not in py_listed + ): + print( + "Python launcher interpreter mismatch: " + f"{py_listed} vs. {py_311_line}, {py_312_line}, {py_313_line}" + ) + sys.exit(1) + + # Check 2: Remove a single interpreter and check that its gone. + # Check 2a: Removing an existing interpreter. + # Check 2b: Remove a missing interpreter. + for _ in range(2): + print("Removing Python 3.11.11") + subprocess.check_call([uv, "python", "uninstall", "-v", "--preview", "3.11.11"]) + listed_interpreters = subprocess.check_output(["py", "--list-paths"], text=True) + py_listed = set(listed_interpreters.splitlines()) + if ( + py_311_line in py_listed + or py_312_line not in py_listed + or py_313_line not in py_listed + ): + print( + "Python launcher interpreter not removed: " + f"{py_listed} vs. {py_312_line}, {py_313_line}" + ) + sys.exit(1) + + # Check 3: Remove all interpreters and check that they are all gone. + # Check 3a: Clear a used registry. + # Check 3b: Clear an empty registry. + subprocess.check_call([uv, "python", "uninstall", "-v", "--preview", "--all"]) + for _ in range(2): + print("Removing all Pythons") + empty_registry = subprocess.check_output( + ["powershell", "-Command", list_registry_command], text=True + ) + if empty_registry.strip(): + print("Registry not cleared:") + print("=" * 80) + print(empty_registry) + print("=" * 80) + sys.exit(1) + listed_interpreters = subprocess.check_output(["py", "--list-paths"], text=True) + py_listed = set(listed_interpreters.splitlines()) + if ( + py_311_line in py_listed + or py_312_line in py_listed + or py_313_line in py_listed + ): + print(f"Python launcher interpreter not cleared: {py_listed}") + sys.exit(1) + + +if __name__ == "__main__": + parser = ArgumentParser() + parser.add_argument("--uv", default="./uv.exe") + args = parser.parse_args() + main(args.uv)