Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
84 changes: 44 additions & 40 deletions crates/uv-python/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<Item = Result<(PythonSource, PathBuf), Error>> + '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<Item = Result<(PythonSource, PathBuf), Error>> + 'a,
> = Box::new(iter::empty());

match preference {
PythonPreference::OnlyManaged => {
Expand Down
5 changes: 5 additions & 0 deletions crates/uv-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
2 changes: 2 additions & 0 deletions crates/uv-settings/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,7 @@ pub struct EnvironmentOptions {
pub hide_build_output: Option<bool>,
pub python_install_bin: Option<bool>,
pub python_install_registry: Option<bool>,
pub python_no_registry: EnvFlag,
pub install_mirrors: PythonInstallMirrors,
pub log_context: Option<bool>,
pub lfs: Option<bool>,
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions crates/uv-static/src/env_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions crates/uv-test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 17 additions & 3 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
62 changes: 58 additions & 4 deletions crates/uv/tests/it/python_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1233,8 +1233,8 @@ fn python_install_freethreaded() {
/// Regression test for <https://github.com/astral-sh/uv/issues/18795>.
///
/// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're now adding more and don't have isolation

#[cfg(all(windows, feature = "test-windows-registry"))]
#[test]
fn python_install_freethreaded_and_gil_list() {
Expand All @@ -1254,20 +1254,22 @@ 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();
context
.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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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!(&[])
Expand Down
Loading