From f5ce5b47c816b371f68c91f4518474401157dd53 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 13 Nov 2025 09:55:48 -0600 Subject: [PATCH] Add support for `--upgrade` in `uv python install` (#16676) This allows us to suggest `uv python install --upgrade 3.14` as the canonical way to get the latest patch version of a given Python regardless of whether it is installed already. Currently, you can do `uv python upgrade 3.14` and it will install it, but I'd like to remove that behavior as I find it very surprising. --- crates/uv-cli/src/lib.rs | 13 ++ crates/uv/src/commands/mod.rs | 1 + crates/uv/src/commands/python/install.rs | 166 +++++++++++++++------ crates/uv/src/lib.rs | 6 +- crates/uv/src/settings.rs | 8 + crates/uv/tests/it/help.rs | 14 ++ crates/uv/tests/it/python_install.rs | 178 ++++++++++++++++++++++- docs/reference/cli.md | 4 + 8 files changed, 341 insertions(+), 49 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 922d8c5c3..9a1984070 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -5868,6 +5868,19 @@ pub struct PythonInstallArgs { #[arg(long, short)] pub force: bool, + /// Upgrade existing Python installations to the latest patch version. + /// + /// By default, uv will not upgrade already-installed Python versions to newer patch releases. + /// With `--upgrade`, uv will upgrade to the latest available patch version for the specified + /// minor version(s). + /// + /// If the requested versions are not yet installed, uv will install them. + /// + /// This option is only supported for minor version requests, e.g., `3.12`; uv will exit with an + /// error if a patch version, e.g., `3.12.2`, is requested. + #[arg(long, short = 'U')] + pub upgrade: bool, + /// Use as the default Python version. /// /// By default, only a `python{major}.{minor}` executable is installed, e.g., `python3.10`. When diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 9e7876b50..b1b1fbbda 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -43,6 +43,7 @@ pub(crate) use python::dir::dir as python_dir; pub(crate) use python::find::find as python_find; pub(crate) use python::find::find_script as python_find_script; pub(crate) use python::install::install as python_install; +pub(crate) use python::install::{PythonUpgrade, PythonUpgradeSource}; pub(crate) use python::list::list as python_list; pub(crate) use python::pin::pin as python_pin; pub(crate) use python::uninstall::uninstall as python_uninstall; diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 6486b2cf7..10e9480f7 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -149,6 +149,31 @@ enum InstallErrorKind { Registry, } +#[derive(Debug, Clone, Copy)] +pub(crate) enum PythonUpgradeSource { + /// The user invoked `uv python install --upgrade` + Install, + /// The user invoked `uv python upgrade` + Upgrade, +} + +impl std::fmt::Display for PythonUpgradeSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Install => write!(f, "uv python install --upgrade"), + Self::Upgrade => write!(f, "uv python upgrade"), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PythonUpgrade { + /// Python upgrades are enabled. + Enabled(PythonUpgradeSource), + /// Python upgrades are disabled. + Disabled, +} + /// Download and install Python versions. #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn install( @@ -156,7 +181,7 @@ pub(crate) async fn install( install_dir: Option, targets: Vec, reinstall: bool, - upgrade: bool, + upgrade: PythonUpgrade, bin: Option, registry: Option, force: bool, @@ -183,11 +208,13 @@ pub(crate) async fn install( ); } - if upgrade && !preview.is_enabled(PreviewFeatures::PYTHON_UPGRADE) { - warn_user!( - "`uv python upgrade` is experimental and may change without warning. Pass `--preview-features {}` to disable this warning", - PreviewFeatures::PYTHON_UPGRADE - ); + if let PythonUpgrade::Enabled(source @ PythonUpgradeSource::Upgrade) = upgrade { + if !preview.is_enabled(PreviewFeatures::PYTHON_UPGRADE) { + warn_user!( + "`{source}` is experimental and may change without warning. Pass `--preview-features {}` to disable this warning", + PreviewFeatures::PYTHON_UPGRADE + ); + } } if default && targets.len() > 1 { @@ -207,8 +234,14 @@ pub(crate) async fn install( // Resolve the requests let mut is_default_install = false; let mut is_unspecified_upgrade = false; + // TODO(zanieb): We use this variable to special-case .python-version files, but it'd be nice to + // have generalized request source tracking instead + let mut is_from_python_version_file = false; let requests: Vec<_> = if targets.is_empty() { - if upgrade { + if matches!( + upgrade, + PythonUpgrade::Enabled(PythonUpgradeSource::Upgrade) + ) { is_unspecified_upgrade = true; // On upgrade, derive requests for all of the existing installations let mut minor_version_requests = IndexSet::::default(); @@ -240,6 +273,7 @@ pub(crate) async fn install( ); }) .map(PythonVersionFile::into_versions) + .inspect(|_| is_from_python_version_file = true) .unwrap_or_else(|| { // If no version file is found and no requests were made // TODO(zanieb): We should consider differentiating between a global Python version @@ -265,11 +299,20 @@ pub(crate) async fn install( }; if requests.is_empty() { - if upgrade { - writeln!( - printer.stderr(), - "There are no installed versions to upgrade" - )?; + match upgrade { + PythonUpgrade::Enabled(PythonUpgradeSource::Upgrade) => { + writeln!( + printer.stderr(), + "There are no installed versions to upgrade" + )?; + } + PythonUpgrade::Enabled(PythonUpgradeSource::Install) => { + writeln!( + printer.stderr(), + "No Python versions specified for upgrade; did you mean `uv python upgrade`?" + )?; + } + PythonUpgrade::Disabled => {} } return Ok(ExitStatus::Success); } @@ -287,17 +330,25 @@ pub(crate) async fn install( }) .collect::>(); - if upgrade - && let Some(request) = requests.iter().find(|request| { + if let PythonUpgrade::Enabled(source) = upgrade { + if let Some(request) = requests.iter().find(|request| { request.request.includes_patch() || request.request.includes_prerelease() - }) - { - writeln!( - printer.stderr(), - "error: `uv python upgrade` only accepts minor versions, got: {}", - request.request.to_canonical_string() - )?; - return Ok(ExitStatus::Failure); + }) { + writeln!( + printer.stderr(), + "error: `{source}` only accepts minor versions, got: {}", + request.request.to_canonical_string() + )?; + if is_from_python_version_file { + writeln!( + printer.stderr(), + "\n{}{} The version request came from a `.python-version` file; change the patch version in the file to upgrade instead", + "hint".bold().cyan(), + ":".bold(), + )?; + } + return Ok(ExitStatus::Failure); + } } // Find requests that are already satisfied @@ -361,10 +412,10 @@ pub(crate) async fn install( // If we can find one existing installation that matches the request, it is satisfied requests.iter().partition_map(|request| { if let Some(installation) = existing_installations.iter().find(|installation| { - if upgrade { - // If this is an upgrade, the requested version is a minor version - // but the requested download is the highest patch for that minor - // version. We need to install it unless an exact match is found. + if matches!(upgrade, PythonUpgrade::Enabled(_)) { + // If this is an upgrade, the requested version is a minor version but the + // requested download is the highest patch for that minor version. We need to + // install it unless an exact match is found. request.download.key() == installation.key() } else { request.matches_installation(installation) @@ -498,7 +549,10 @@ pub(crate) async fn install( force, default, upgradeable, - upgrade, + matches!( + upgrade, + PythonUpgrade::Enabled(PythonUpgradeSource::Upgrade) + ), is_default_install, &existing_installations, &installations, @@ -534,7 +588,10 @@ pub(crate) async fn install( ); for installation in minor_versions.values() { - if upgrade { + if matches!( + upgrade, + PythonUpgrade::Enabled(PythonUpgradeSource::Upgrade) + ) { // During an upgrade, update existing symlinks but avoid // creating new ones. installation.update_minor_version_link(preview)?; @@ -545,24 +602,38 @@ pub(crate) async fn install( if changelog.installed.is_empty() && errors.is_empty() { if is_default_install { - writeln!( - printer.stderr(), - "Python is already installed. Use `uv python install ` to install another version.", - )?; - } else if upgrade && requests.is_empty() { + if matches!( + upgrade, + PythonUpgrade::Enabled(PythonUpgradeSource::Install) + ) { + writeln!( + printer.stderr(), + "The default Python installation is already on the latest supported patch release. Use `uv python install ` to install another version.", + )?; + } else { + writeln!( + printer.stderr(), + "Python is already installed. Use `uv python install ` to install another version.", + )?; + } + } else if matches!( + upgrade, + PythonUpgrade::Enabled(PythonUpgradeSource::Upgrade) + ) && requests.is_empty() + { writeln!( printer.stderr(), "There are no installed versions to upgrade" )?; - } else if upgrade && is_unspecified_upgrade { - writeln!( - printer.stderr(), - "All versions already on latest supported patch release" - )?; } else if let [request] = requests.as_slice() { // Convert to the inner request let request = &request.request; - if upgrade { + if is_unspecified_upgrade { + writeln!( + printer.stderr(), + "All versions already on latest supported patch release" + )?; + } else if matches!(upgrade, PythonUpgrade::Enabled(_)) { writeln!( printer.stderr(), "{request} is already on the latest supported patch release" @@ -571,11 +642,18 @@ pub(crate) async fn install( writeln!(printer.stderr(), "{request} is already installed")?; } } else { - if upgrade { - writeln!( - printer.stderr(), - "All requested versions already on latest supported patch release" - )?; + if matches!(upgrade, PythonUpgrade::Enabled(_)) { + if is_unspecified_upgrade { + writeln!( + printer.stderr(), + "All versions already on latest supported patch release" + )?; + } else { + writeln!( + printer.stderr(), + "All requested versions already on latest supported patch release" + )?; + } } else { writeln!(printer.stderr(), "All requested versions already installed")?; } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 568d25f9f..1df8c1e67 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1543,15 +1543,13 @@ async fn run(mut cli: Cli) -> Result { // Resolve the settings from the command-line arguments and workspace configuration. let args = settings::PythonInstallSettings::resolve(args, filesystem, environment); show_settings!(args); - // TODO(john): If we later want to support `--upgrade`, we need to replace this. - let upgrade = false; commands::python_install( &project_dir, args.install_dir, args.targets, args.reinstall, - upgrade, + args.upgrade, args.bin, args.registry, args.force, @@ -1573,7 +1571,7 @@ async fn run(mut cli: Cli) -> Result { // Resolve the settings from the command-line arguments and workspace configuration. let args = settings::PythonUpgradeSettings::resolve(args, filesystem, environment); show_settings!(args); - let upgrade = true; + let upgrade = commands::PythonUpgrade::Enabled(commands::PythonUpgradeSource::Upgrade); commands::python_install( &project_dir, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 9f6b0d8c4..855d046c2 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -5,6 +5,7 @@ use std::process; use std::str::FromStr; use std::time::Duration; +use crate::commands::{PythonUpgrade, PythonUpgradeSource}; use uv_auth::Service; use uv_cache::{CacheArgs, Refresh}; use uv_cli::comma::CommaSeparatedRequirements; @@ -1061,6 +1062,7 @@ pub(crate) struct PythonInstallSettings { pub(crate) targets: Vec, pub(crate) reinstall: bool, pub(crate) force: bool, + pub(crate) upgrade: PythonUpgrade, pub(crate) bin: Option, pub(crate) registry: Option, pub(crate) python_install_mirror: Option, @@ -1101,6 +1103,7 @@ impl PythonInstallSettings { registry, no_registry, force, + upgrade, mirror: _, pypy_mirror: _, python_downloads_json_url: _, @@ -1112,6 +1115,11 @@ impl PythonInstallSettings { targets, reinstall, force, + upgrade: if upgrade { + PythonUpgrade::Enabled(PythonUpgradeSource::Install) + } else { + PythonUpgrade::Disabled + }, bin: flag(bin, no_bin, "bin").or(environment.python_install_bin), registry: flag(registry, no_registry, "registry") .or(environment.python_install_registry), diff --git a/crates/uv/tests/it/help.rs b/crates/uv/tests/it/help.rs index 9a70422dd..648a34ece 100644 --- a/crates/uv/tests/it/help.rs +++ b/crates/uv/tests/it/help.rs @@ -557,6 +557,18 @@ fn help_subsubcommand() { Implies `--reinstall`. + -U, --upgrade + Upgrade existing Python installations to the latest patch version. + + By default, uv will not upgrade already-installed Python versions to newer patch releases. + With `--upgrade`, uv will upgrade to the latest available patch version for the specified + minor version(s). + + If the requested versions are not yet installed, uv will install them. + + This option is only supported for minor version requests, e.g., `3.12`; uv will exit with + an error if a patch version, e.g., `3.12.2`, is requested. + --default Use as the default Python version. @@ -819,6 +831,8 @@ fn help_flag_subsubcommand() { Reinstall the requested Python version, if it's already installed -f, --force Replace existing Python executables during installation + -U, --upgrade + Upgrade existing Python installations to the latest patch version --default Use as the default Python version diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index 86da14eb4..b56207f86 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -1379,7 +1379,8 @@ fn python_install_debug_freethreaded() { let context: TestContext = TestContext::new_with_versions(&[]) .with_filtered_python_keys() .with_filtered_exe_suffix() - .with_managed_python_dirs(); + .with_managed_python_dirs() + .with_python_download_cache(); // Install the latest version uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.13td"), @r" @@ -2748,6 +2749,7 @@ fn python_install_emulated_macos() { if !arch_status.is_ok_and(|x| x.success()) { // Rosetta is not available to run the x86_64 interpreter // fail the test in CI, otherwise skip it + #[allow(clippy::manual_assert)] if env::var("CI").is_ok() { panic!("x86_64 emulation is not available on this CI runner"); } @@ -3774,3 +3776,177 @@ fn python_install_build_version_pypy() { error: No download found for request: pypy-3.10-[PLATFORM] "); } + +#[test] +fn python_install_upgrade() { + let context: TestContext = TestContext::new_with_versions(&[]) + .with_python_download_cache() + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_managed_python_dirs(); + + // Provide `--upgrade` as an `install` option without any versions + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.14.0 in [TIME] + + cpython-3.14.0-[PLATFORM] (python3.14) + "); + + // Provide `--upgrade` as an `install` option without any versions again! + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + The default Python installation is already on the latest supported patch release. Use `uv python install ` to install another version. + "); + + // Install an earlier patch version + uv_snapshot!(context.filters(), context.python_install().arg("3.10.17"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.10.17 in [TIME] + + cpython-3.10.17-[PLATFORM] (python3.10) + "); + + // Ask for an `--upgrade` + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade").arg("3.10"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.10.19 in [TIME] + + cpython-3.10.19-[PLATFORM] (python3.10) + "); + + // Request a patch version with `--upgrade` + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade").arg("3.11.4"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + error: `uv python install --upgrade` only accepts minor versions, got: 3.11.4 + "); + + // Request a version that isn't installed yet + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade").arg("3.11"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.11.14 in [TIME] + + cpython-3.11.14-[PLATFORM] (python3.11) + "); + + // Ask for it again + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade").arg("3.11"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Python 3.11 is already on the latest supported patch release + "); + + // Install an outdated version + uv_snapshot!(context.filters(), context.python_install().arg("3.9.5"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.9.5 in [TIME] + + cpython-3.9.5-[PLATFORM] (python3.9) + "); + + // We shouldn't update it when not relevant + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade").arg("3.11"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Python 3.11 is already on the latest supported patch release + "); + + // Ask for multiple already satisfied versions + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade").arg("3.10").arg("3.11"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + All requested versions already on latest supported patch release + "); + + // Mix in an unsatisfied version and a missing one + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade").arg("3.9").arg("3.10").arg("3.11").arg("3.12"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed 2 versions in [TIME] + + cpython-3.9.25-[PLATFORM] (python3.9) + + cpython-3.12.12-[PLATFORM] (python3.12) + "); +} + +#[test] +fn python_install_upgrade_version_file() { + let context: TestContext = TestContext::new_with_versions(&[]) + .with_python_download_cache() + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_managed_python_dirs(); + + // Pin to a minor version + context.python_pin().arg("3.13").assert().success(); + + // Provide `--upgrade` as an `install` option without any versions + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.13.9 in [TIME] + + cpython-3.13.9-[PLATFORM] (python3.13) + "); + + // Provide `--upgrade` as an `install` option without any versions again! + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Python 3.13 is already on the latest supported patch release + "); + + // Pin to a patch version + context.python_pin().arg("3.12.4").assert().success(); + + // Provide `--upgrade` as an `install` option without any versions + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + error: `uv python install --upgrade` only accepts minor versions, got: 3.12.4 + + hint: The version request came from a `.python-version` file; change the patch version in the file to upgrade instead + "); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1224bfff7..59272a6fc 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -3525,6 +3525,10 @@ uv python install [OPTIONS] [TARGETS]...

Repeating this option, e.g., -qq, will enable a silent mode in which uv will write no output to stdout.

--reinstall, -r

Reinstall the requested Python version, if it's already installed.

By default, uv will exit successfully if the version is already installed.

+
--upgrade, -U

Upgrade existing Python installations to the latest patch version.

+

By default, uv will not upgrade already-installed Python versions to newer patch releases. With --upgrade, uv will upgrade to the latest available patch version for the specified minor version(s).

+

If the requested versions are not yet installed, uv will install them.

+

This option is only supported for minor version requests, e.g., 3.12; uv will exit with an error if a patch version, e.g., 3.12.2, is requested.

--verbose, -v

Use verbose output.

You can configure fine-grained logging using the RUST_LOG environment variable. (https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives)