From bb1e9a247c5e488a712e8f1cc040f025f9751337 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 15 Jul 2025 12:12:36 -0500 Subject: [PATCH] Update preview installation of Python executables to be non-fatal (#14612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, if installation of executables into the bin directory failed we'd with a non-zero code. However, if we make this behavior the default we don't want it to be fatal. There's a `--bin` opt-in to _require_ successful executable installation and a `--no-bin` opt-out to silence the warning / opt-out of installation entirely. Part of https://github.com/astral-sh/uv/issues/14296 — we need this before we can stabilize the behavior. In #14614 we do the same for writing entries to the Windows registry. --- crates/uv-cli/src/lib.rs | 15 ++- crates/uv-python/src/windows_registry.rs | 7 +- crates/uv/src/commands/python/install.rs | 145 +++++++++++++++++------ crates/uv/src/lib.rs | 2 + crates/uv/src/settings.rs | 7 ++ crates/uv/tests/it/help.rs | 5 + crates/uv/tests/it/python_install.rs | 68 ++++++++++- docs/reference/cli.md | 3 +- 8 files changed, 212 insertions(+), 40 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 0f3652341..70d5322d9 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4941,6 +4941,19 @@ pub struct PythonInstallArgs { #[arg(long, short, env = EnvVars::UV_PYTHON_INSTALL_DIR)] pub install_dir: Option, + /// Install a Python executable into the `bin` directory. + /// + /// This is the default behavior. If this flag is provided explicitly, uv will error if the + /// executable cannot be installed. + /// + /// See `UV_PYTHON_BIN_DIR` to customize the target directory. + #[arg(long, overrides_with("no_bin"), hide = true)] + pub bin: bool, + + /// Do not install a Python executable into the `bin` directory. + #[arg(long, overrides_with("bin"), conflicts_with("default"))] + pub no_bin: bool, + /// The Python version(s) to install. /// /// If not provided, the requested Python version(s) will be read from the `UV_PYTHON` @@ -5003,7 +5016,7 @@ pub struct PythonInstallArgs { /// and `python`. /// /// If multiple Python versions are requested, uv will exit with an error. - #[arg(long)] + #[arg(long, conflicts_with("no_bin"))] pub default: bool, } diff --git a/crates/uv-python/src/windows_registry.rs b/crates/uv-python/src/windows_registry.rs index 69e179bbf..7c6f6f307 100644 --- a/crates/uv-python/src/windows_registry.rs +++ b/crates/uv-python/src/windows_registry.rs @@ -129,12 +129,13 @@ fn read_registry_entry(company: &str, tag: &str, tag_key: &Key) -> Option, ) -> Result<(), ManagedPep514Error> { let pointer_width = match installation.key().arch().family().pointer_width() { Ok(PointerWidth::U32) => 32, @@ -146,9 +147,7 @@ pub fn create_registry_entry( } }; - if let Err(err) = write_registry_entry(installation, pointer_width) { - errors.push((installation.key().clone(), err.into())); - } + write_registry_entry(installation, pointer_width)?; Ok(()) } diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 8c8387d07..b22d6010e 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -135,6 +135,14 @@ impl Changelog { } } +#[derive(Debug, Clone, Copy)] +enum InstallErrorKind { + DownloadUnpack, + Bin, + #[cfg(windows)] + Registry, +} + /// Download and install Python versions. #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn install( @@ -143,6 +151,7 @@ pub(crate) async fn install( targets: Vec, reinstall: bool, upgrade: bool, + bin: Option, force: bool, python_install_mirror: Option, pypy_install_mirror: Option, @@ -432,12 +441,16 @@ pub(crate) async fn install( downloaded.push(installation.clone()); } Err(err) => { - errors.push((download.key().clone(), anyhow::Error::new(err))); + errors.push(( + InstallErrorKind::DownloadUnpack, + download.key().clone(), + anyhow::Error::new(err), + )); } } } - let bin = if preview.is_enabled() { + let bin_dir = if matches!(bin, Some(true)) || preview.is_enabled() { Some(python_executable_dir()?) } else { None @@ -460,7 +473,7 @@ pub(crate) async fn install( continue; } - let bin = bin + let bin_dir = bin_dir .as_ref() .expect("We should have a bin directory with preview enabled") .as_path(); @@ -468,27 +481,38 @@ pub(crate) async fn install( let upgradeable = (default || is_default_install) || requested_minor_versions.contains(&installation.key().version().python_version()); - create_bin_links( - installation, - bin, - reinstall, - force, - default, - upgradeable, - upgrade, - is_default_install, - first_request, - &existing_installations, - &installations, - &mut changelog, - &mut errors, - preview, - )?; + if !matches!(bin, Some(false)) { + create_bin_links( + installation, + bin_dir, + reinstall, + force, + default, + upgradeable, + upgrade, + is_default_install, + first_request, + &existing_installations, + &installations, + &mut changelog, + &mut errors, + preview, + ); + } if preview.is_enabled() { #[cfg(windows)] { - uv_python::windows_registry::create_registry_entry(installation, &mut errors)?; + match uv_python::windows_registry::create_registry_entry(installation) { + Ok(()) => {} + Err(err) => { + errors.push(( + InstallErrorKind::Registry, + installation.key().clone(), + err.into(), + )); + } + } } } } @@ -636,24 +660,47 @@ pub(crate) async fn install( } } - if preview.is_enabled() { - let bin = bin + if preview.is_enabled() && !matches!(bin, Some(false)) { + let bin_dir = bin_dir .as_ref() .expect("We should have a bin directory with preview enabled") .as_path(); - warn_if_not_on_path(bin); + warn_if_not_on_path(bin_dir); } } if !errors.is_empty() { - for (key, err) in errors + // If there are only bin install errors and the user didn't opt-in, we're only going to warn + let fatal = errors + .iter() + .all(|(kind, _, _)| matches!(kind, InstallErrorKind::Bin)) + && bin.is_none(); + + for (kind, key, err) in errors .into_iter() - .sorted_unstable_by(|(key_a, _), (key_b, _)| key_a.cmp(key_b)) + .sorted_unstable_by(|(_, key_a, _), (_, key_b, _)| key_a.cmp(key_b)) { + let (level, verb) = match kind { + InstallErrorKind::DownloadUnpack => ("error".red().bold().to_string(), "install"), + InstallErrorKind::Bin => { + let level = match bin { + None => "warning".yellow().bold().to_string(), + Some(false) => continue, + Some(true) => "error".red().bold().to_string(), + }; + (level, "install executable for") + } + #[cfg(windows)] + InstallErrorKind::Registry => ( + "error".red().bold().to_string(), + "install registry entry for", + ), + }; + writeln!( printer.stderr(), - "{}: Failed to install {}", - "error".red().bold(), + "{level}{} Failed to {verb} {}", + ":".bold(), key.green() )?; for err in err.chain() { @@ -665,6 +712,11 @@ pub(crate) async fn install( )?; } } + + if fatal { + return Ok(ExitStatus::Success); + } + return Ok(ExitStatus::Failure); } @@ -672,6 +724,8 @@ pub(crate) async fn install( } /// Link the binaries of a managed Python installation to the bin directory. +/// +/// This function is fallible, but errors are pushed to `errors` instead of being thrown. #[allow(clippy::fn_params_excessive_bools)] fn create_bin_links( installation: &ManagedPythonInstallation, @@ -686,9 +740,9 @@ fn create_bin_links( existing_installations: &[ManagedPythonInstallation], installations: &[&ManagedPythonInstallation], changelog: &mut Changelog, - errors: &mut Vec<(PythonInstallationKey, Error)>, + errors: &mut Vec<(InstallErrorKind, PythonInstallationKey, Error)>, preview: PreviewMode, -) -> Result<(), Error> { +) { let targets = if (default || is_default_install) && first_request.matches_installation(installation) { vec![ @@ -773,6 +827,7 @@ fn create_bin_links( ); } else { errors.push(( + InstallErrorKind::Bin, installation.key().clone(), anyhow::anyhow!( "Executable already exists at `{}` but is not managed by uv; use `--force` to replace it", @@ -848,7 +903,17 @@ fn create_bin_links( } // Replace the existing link - fs_err::remove_file(&to)?; + if let Err(err) = fs_err::remove_file(&to) { + errors.push(( + InstallErrorKind::Bin, + installation.key().clone(), + anyhow::anyhow!( + "Executable already exists at `{}` but could not be removed: {err}", + to.simplified_display() + ), + )); + continue; + } if let Some(existing) = existing { // Ensure we do not report installation of this executable for an existing @@ -860,7 +925,18 @@ fn create_bin_links( .remove(&target); } - create_link_to_executable(&target, executable)?; + if let Err(err) = create_link_to_executable(&target, executable) { + errors.push(( + InstallErrorKind::Bin, + installation.key().clone(), + anyhow::anyhow!( + "Failed to create link at `{}`: {err}", + target.simplified_display() + ), + )); + continue; + } + debug!( "Updated executable at `{}` to {}", target.simplified_display(), @@ -874,11 +950,14 @@ fn create_bin_links( .insert(target.clone()); } Err(err) => { - errors.push((installation.key().clone(), anyhow::Error::new(err))); + errors.push(( + InstallErrorKind::Bin, + installation.key().clone(), + anyhow::Error::new(err), + )); } } } - Ok(()) } pub(crate) fn format_executables( diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 0b4d0bb82..3a700b965 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1402,6 +1402,7 @@ async fn run(mut cli: Cli) -> Result { args.targets, args.reinstall, upgrade, + args.bin, args.force, args.python_install_mirror, args.pypy_install_mirror, @@ -1430,6 +1431,7 @@ async fn run(mut cli: Cli) -> Result { args.targets, reinstall, upgrade, + args.bin, args.force, args.python_install_mirror, args.pypy_install_mirror, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 8a325d538..d373250ac 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -933,6 +933,7 @@ pub(crate) struct PythonInstallSettings { pub(crate) targets: Vec, pub(crate) reinstall: bool, pub(crate) force: bool, + pub(crate) bin: Option, pub(crate) python_install_mirror: Option, pub(crate) pypy_install_mirror: Option, pub(crate) python_downloads_json_url: Option, @@ -961,6 +962,8 @@ impl PythonInstallSettings { install_dir, targets, reinstall, + bin, + no_bin, force, mirror: _, pypy_mirror: _, @@ -973,6 +976,7 @@ impl PythonInstallSettings { targets, reinstall, force, + bin: flag(bin, no_bin, "bin"), python_install_mirror: python_mirror, pypy_install_mirror: pypy_mirror, python_downloads_json_url, @@ -992,6 +996,7 @@ pub(crate) struct PythonUpgradeSettings { pub(crate) pypy_install_mirror: Option, pub(crate) python_downloads_json_url: Option, pub(crate) default: bool, + pub(crate) bin: Option, } impl PythonUpgradeSettings { @@ -1013,6 +1018,7 @@ impl PythonUpgradeSettings { args.python_downloads_json_url.or(python_downloads_json_url); let force = false; let default = false; + let bin = None; let PythonUpgradeArgs { install_dir, @@ -1030,6 +1036,7 @@ impl PythonUpgradeSettings { pypy_install_mirror: pypy_mirror, python_downloads_json_url, default, + bin, } } } diff --git a/crates/uv/tests/it/help.rs b/crates/uv/tests/it/help.rs index 8faebd040..a6230108c 100644 --- a/crates/uv/tests/it/help.rs +++ b/crates/uv/tests/it/help.rs @@ -504,6 +504,9 @@ fn help_subsubcommand() { [env: UV_PYTHON_INSTALL_DIR=] + --no-bin + Do not install a Python executable into the `bin` directory + --mirror Set the URL to use as the source for downloading Python installations. @@ -790,6 +793,8 @@ fn help_flag_subsubcommand() { Options: -i, --install-dir The directory to store the Python installation in [env: UV_PYTHON_INSTALL_DIR=] + --no-bin + Do not install a Python executable into the `bin` directory --mirror Set the URL to use as the source for downloading Python installations [env: UV_PYTHON_INSTALL_MIRROR=] diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index bd723e5d1..0cb952054 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -430,15 +430,35 @@ fn python_install_preview() { bin_python.touch().unwrap(); uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.13"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Failed to install executable for cpython-3.13.5-[PLATFORM] + Caused by: Executable already exists at `[BIN]/python3.13` but is not managed by uv; use `--force` to replace it + "); + + // With `--bin`, this should error instead of warn + uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("--bin").arg("3.13"), @r" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Failed to install cpython-3.13.5-[PLATFORM] + error: Failed to install executable for cpython-3.13.5-[PLATFORM] Caused by: Executable already exists at `[BIN]/python3.13` but is not managed by uv; use `--force` to replace it "); + // With `--no-bin`, this should be silent + uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("--no-bin").arg("3.13"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "); + uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("--force").arg("3.13"), @r" success: true exit_code: 0 @@ -565,6 +585,52 @@ fn python_install_preview() { } } +#[test] +fn python_install_preview_no_bin() { + let context: TestContext = TestContext::new_with_versions(&[]) + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_managed_python_dirs(); + + // Install the latest version + uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("--no-bin"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.13.5 in [TIME] + + cpython-3.13.5-[PLATFORM] + "); + + let bin_python = context + .bin_dir + .child(format!("python3.13{}", std::env::consts::EXE_SUFFIX)); + + // The executable should not be installed in the bin directory + bin_python.assert(predicate::path::missing()); + + uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("--no-bin").arg("--default"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: the argument '--no-bin' cannot be used with '--default' + + Usage: uv python install --no-bin --install-dir [TARGETS]... + + For more information, try '--help'. + "); + + let bin_python = context + .bin_dir + .child(format!("python{}", std::env::consts::EXE_SUFFIX)); + + // The executable should not be installed in the bin directory + bin_python.assert(predicate::path::missing()); +} + #[test] fn python_install_preview_upgrade() { let context = TestContext::new_with_versions(&[]) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 13df63c19..93d928518 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2795,7 +2795,8 @@ uv python install [OPTIONS] [TARGETS]...

May also be set with the UV_PYTHON_INSTALL_MIRROR environment variable.

--native-tls

Whether to load TLS certificates from the platform's native certificate store.

By default, uv loads certificates from the bundled webpki-roots crate. The webpki-roots are a reliable set of trust roots from Mozilla, and including them in uv improves portability and performance (especially on macOS).

However, in some cases, you may want to use the platform's native certificate store, especially if you're relying on a corporate trust root (e.g., for a mandatory proxy) that's included in your system's certificate store.

-

May also be set with the UV_NATIVE_TLS environment variable.

--no-cache, --no-cache-dir, -n

Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation

+

May also be set with the UV_NATIVE_TLS environment variable.

--no-bin

Do not install a Python executable into the bin directory

+
--no-cache, --no-cache-dir, -n

Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation

May also be set with the UV_NO_CACHE environment variable.

--no-config

Avoid discovering configuration files (pyproject.toml, uv.toml).

Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.

May also be set with the UV_NO_CONFIG environment variable.

--no-managed-python

Disable use of uv-managed Python versions.