diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 3d8179ce925f6..f52c00594b644 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -274,6 +274,9 @@ pub enum Error { #[error("Failed to query installed Python versions from the Windows registry")] RegistryError(#[from] windows::core::Error), + #[error(transparent)] + InvalidEnvironmentVariable(#[from] uv_static::InvalidEnvironmentVariable), + /// An invalid version request was given #[error("Invalid version request: {0}")] InvalidVersionRequest(String), @@ -441,49 +444,50 @@ fn python_executables_from_installed<'a>( }) .flatten(); - let from_windows_registry = iter::once_with(move || { - #[cfg(windows)] - { - // Skip interpreter probing if we already know the version doesn't match. - let version_filter = move |entry: &WindowsPython| { - if let Some(found) = &entry.version { - // Some distributions emit the patch version (example: `SysVersion: 3.9`) - if found.string.chars().filter(|c| *c == '.').count() == 1 { - version.matches_major_minor(found.major(), found.minor()) + #[cfg(windows)] + let from_windows_registry: Box< + dyn Iterator> + 'a, + > = match uv_static::parse_boolish_environment_variable(EnvVars::UV_PYTHON_NO_REGISTRY) { + Ok(Some(true)) => Box::new(iter::empty()), + Ok(Some(false) | None) => Box::new( + iter::once_with(move || { + // Skip interpreter probing if we already know the version doesn't match. + let version_filter = move |entry: &WindowsPython| { + if let Some(found) = &entry.version { + // Some distributions emit the patch version (example: `SysVersion: 3.9`) + if found.string.chars().filter(|c| *c == '.').count() == 1 { + version.matches_major_minor(found.major(), found.minor()) + } else { + version.matches_version(found) + } } else { - version.matches_version(found) + true } - } else { - true - } - }; + }; - env::var_os(EnvVars::UV_TEST_PYTHON_PATH) - .is_none() - .then(|| { - registry_pythons() - .map(|entries| { - entries - .into_iter() - .filter(version_filter) - .map(|entry| (PythonSource::Registry, entry.path)) - .chain( - find_microsoft_store_pythons() - .filter(version_filter) - .map(|entry| (PythonSource::MicrosoftStore, entry.path)), - ) - }) - .map_err(Error::from) - }) - .into_iter() - .flatten_ok() - } - #[cfg(not(windows))] - { - Vec::new() - } - }) - .flatten(); + registry_pythons() + .map(|entries| { + entries + .into_iter() + .filter(version_filter) + .map(|entry| (PythonSource::Registry, entry.path)) + .chain( + find_microsoft_store_pythons() + .filter(version_filter) + .map(|entry| (PythonSource::MicrosoftStore, entry.path)), + ) + }) + .map_err(Error::from) + }) + .flatten_ok(), + ), + Err(err) => Box::new(iter::once(Err(Error::from(err)))), + }; + + #[cfg(not(windows))] + let from_windows_registry: Box< + dyn Iterator> + 'a, + > = Box::new(iter::empty()); match preference { PythonPreference::OnlyManaged => { diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index e7d253380e4ee..6c0ba9ab2b008 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -220,6 +220,11 @@ mod tests { let mut run_vars = vec![ // Ensure `PATH` is used (EnvVars::UV_TEST_PYTHON_PATH, None), + // Keep discovery hermetic by disabling registry-based sources unless a test opts in. + ( + EnvVars::UV_PYTHON_NO_REGISTRY, + Some(std::ffi::OsStr::new("1")), + ), // Ignore active virtual environments (i.e. that the dev is using) (EnvVars::VIRTUAL_ENV, None), (EnvVars::PATH, path.as_deref()), diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index feba1a0d8788d..ec5d8587675a5 100644 --- a/crates/uv-settings/src/lib.rs +++ b/crates/uv-settings/src/lib.rs @@ -717,6 +717,7 @@ pub struct EnvironmentOptions { pub hide_build_output: Option, pub python_install_bin: Option, pub python_install_registry: Option, + pub python_no_registry: EnvFlag, pub install_mirrors: PythonInstallMirrors, pub log_context: Option, pub lfs: Option, @@ -780,6 +781,7 @@ impl EnvironmentOptions { python_install_registry: parse_boolish_environment_variable( EnvVars::UV_PYTHON_INSTALL_REGISTRY, )?, + python_no_registry: EnvFlag::new(EnvVars::UV_PYTHON_NO_REGISTRY)?, concurrency: Concurrency { downloads: parse_integer_environment_variable( EnvVars::UV_CONCURRENT_DOWNLOADS, diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index a66561d794af9..9613ecf8017c4 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -420,6 +420,14 @@ impl EnvVars { #[attr_added_in("0.8.0")] pub const UV_PYTHON_INSTALL_REGISTRY: &'static str = "UV_PYTHON_INSTALL_REGISTRY"; + /// Disable use of the Windows registry for Python discovery and registration. + /// + /// When set, uv will not discover Python interpreters from the Windows registry or Microsoft + /// Store locations, and managed Python installations will not be registered in the Windows + /// registry. + #[attr_added_in("next release")] + pub const UV_PYTHON_NO_REGISTRY: &'static str = "UV_PYTHON_NO_REGISTRY"; + /// Managed Python installations information is hardcoded in the `uv` binary. /// /// This variable can be set to a local path or URL pointing to diff --git a/crates/uv-test/src/lib.rs b/crates/uv-test/src/lib.rs index 0549407def3f8..ba21a0c0230fc 100755 --- a/crates/uv-test/src/lib.rs +++ b/crates/uv-test/src/lib.rs @@ -1236,8 +1236,9 @@ impl TestContext { .env(EnvVars::UV_EXCLUDE_NEWER, TEST_TIMESTAMP) .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, TEST_TIMESTAMP) .env(EnvVars::UV_TEST_AVAILABLE_VERSION_CUTOFF, TEST_TIMESTAMP) - // When installations are allowed, we don't want to write to global state, like the - // Windows registry + // Keep Python discovery hermetic and avoid mutating global state, like the Windows + // registry, unless a test opts in explicitly. + .env(EnvVars::UV_PYTHON_NO_REGISTRY, "1") .env(EnvVars::UV_PYTHON_INSTALL_REGISTRY, "0") // Since downloads, fetches and builds run in parallel, their message output order is // non-deterministic, so can't capture them in test output. diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index bc8ac253c927a..53afa8d5a83b9 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1372,8 +1372,16 @@ impl PythonInstallSettings { PythonUpgrade::Disabled }, bin: flag(bin, no_bin, "bin").or(environment.python_install_bin), - registry: flag(registry, no_registry, "registry") - .or(environment.python_install_registry), + registry: match flag(registry, no_registry, "registry") { + Some(registry) => Some(registry), + None => environment.python_install_registry.or( + if environment.python_no_registry.value == Some(true) { + Some(false) + } else { + None + }, + ), + }, python_install_mirror, pypy_install_mirror, python_downloads_json_url, @@ -1430,7 +1438,13 @@ impl PythonUpgradeSettings { let force = false; let default = false; let bin = None; - let registry = None; + let registry = environment.python_install_registry.or( + if environment.python_no_registry.value == Some(true) { + Some(false) + } else { + None + }, + ); let PythonUpgradeArgs { install_dir, diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index dbbfac16e6549..ef0ce260f9067 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -1233,8 +1233,8 @@ fn python_install_freethreaded() { /// Regression test for . /// /// IMPORTANT: this test writes to the shared `HKCU` registry. The trailing uninstall is -/// best-effort cleanup; panics will leak entries. This is fine for now since this is the only -/// test exercising the registry pathway, but adding more will probably require isolation. +/// best-effort cleanup; panics will leak entries. These registry tests still share global state, +/// so adding more will probably require isolation. #[cfg(all(windows, feature = "test-windows-registry"))] #[test] fn python_install_freethreaded_and_gil_list() { @@ -1254,6 +1254,7 @@ fn python_install_freethreaded_and_gil_list() { context .python_install() .arg("3.13") + .env_remove(EnvVars::UV_PYTHON_NO_REGISTRY) .env(EnvVars::UV_PYTHON_INSTALL_REGISTRY, "1") .assert() .success(); @@ -1261,13 +1262,14 @@ fn python_install_freethreaded_and_gil_list() { .python_install() .arg("--preview") .arg("3.13t") + .env_remove(EnvVars::UV_PYTHON_NO_REGISTRY) .env(EnvVars::UV_PYTHON_INSTALL_REGISTRY, "1") .assert() .success(); // List installed versions with registry discovery enabled. - // We remove UV_TEST_PYTHON_PATH to enable registry discovery (it's skipped when set), - // and use `--managed-python --only-installed` to exclude unrelated system Pythons. + // We remove `UV_PYTHON_NO_REGISTRY` to opt back into registry discovery, and remove + // `UV_TEST_PYTHON_PATH` so the test can discover the installed bin trampolines. // // Both the GIL and freethreaded variants should show entries from: // - The registry (patch-versioned managed directory path) @@ -1277,6 +1279,7 @@ fn python_install_freethreaded_and_gil_list() { .arg("3.13") .arg("--only-installed") .arg("--managed-python") + .env_remove(EnvVars::UV_PYTHON_NO_REGISTRY) .env_remove(EnvVars::UV_TEST_PYTHON_PATH) .env(EnvVars::UV_PYTHON_INSTALL_REGISTRY, "1"), @" success: true @@ -1293,6 +1296,7 @@ fn python_install_freethreaded_and_gil_list() { .arg("3.13t") .arg("--only-installed") .arg("--managed-python") + .env_remove(EnvVars::UV_PYTHON_NO_REGISTRY) .env_remove(EnvVars::UV_TEST_PYTHON_PATH) .env(EnvVars::UV_PYTHON_INSTALL_REGISTRY, "1"), @" success: true @@ -1314,6 +1318,56 @@ fn python_install_freethreaded_and_gil_list() { .success(); } +#[cfg(all(windows, feature = "test-windows-registry"))] +#[test] +fn python_install_registry_takes_precedence_over_no_registry() { + use assert_cmd::assert::OutputAssertExt; + + let context = uv_test::test_context_with_versions!(&[]) + .with_filtered_python_keys() + .with_filtered_latest_python_versions() + .with_managed_python_dirs() + .with_python_download_cache() + .with_filtered_python_install_bin() + .with_filtered_python_names() + .with_filtered_exe_suffix() + .with_collapsed_whitespace(); + + context + .python_install() + .arg("3.13") + .env(EnvVars::UV_PYTHON_INSTALL_REGISTRY, "1") + .env(EnvVars::UV_PYTHON_NO_REGISTRY, "1") + .assert() + .success(); + + // `UV_PYTHON_INSTALL_REGISTRY` should take precedence over `UV_PYTHON_NO_REGISTRY`. + // When we re-enable registry discovery for this command and clear the search path, we should + // see both the registry entry and the managed installation entry. + uv_snapshot!(context.filters(), context.python_list() + .arg("3.13") + .arg("--only-installed") + .arg("--managed-python") + .env_remove(EnvVars::UV_PYTHON_NO_REGISTRY) + .env(EnvVars::UV_TEST_PYTHON_PATH, "") + .env(EnvVars::UV_PYTHON_INSTALL_REGISTRY, "1"), @" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.13.[LATEST]-[PLATFORM] managed/cpython-3.13.[LATEST]-[PLATFORM]/[INSTALL-BIN]/[PYTHON] + cpython-3.13.[LATEST]-[PLATFORM] managed/cpython-3.13-[PLATFORM]/[INSTALL-BIN]/[PYTHON] + + ----- stderr ----- + "); + + context + .python_uninstall() + .arg("--all") + .env(EnvVars::UV_PYTHON_INSTALL_REGISTRY, "1") + .assert() + .success(); +} + #[test] fn python_upgrade_not_allowed() { let context = uv_test::test_context_with_versions!(&[])