From 1fbc1c7ff4c587292e60bf4706ca31a9dadffc3a Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 23 Oct 2025 10:48:34 -0500 Subject: [PATCH] Check for matching Python implementation during `uv python upgrade` (#16420) Closes https://github.com/astral-sh/uv/issues/16416 --- crates/uv-pep440/src/version.rs | 7 ++++++ crates/uv-pep440/src/version_specifier.rs | 9 ++++++++ crates/uv-python/src/discovery.rs | 25 +++++++++++++++++---- crates/uv-python/src/downloads.rs | 4 ++++ crates/uv/src/commands/python/install.rs | 21 +++++++++++------- crates/uv/tests/it/python_install.rs | 4 ++-- crates/uv/tests/it/python_upgrade.rs | 27 ++++++++++++++++++++++- 7 files changed, 82 insertions(+), 15 deletions(-) diff --git a/crates/uv-pep440/src/version.rs b/crates/uv-pep440/src/version.rs index 169eb795a..d518675c7 100644 --- a/crates/uv-pep440/src/version.rs +++ b/crates/uv-pep440/src/version.rs @@ -615,6 +615,13 @@ impl Version { Self::new(self.release().iter().copied()) } + /// Return the version with any segments apart from the minor version of the release removed. + #[inline] + #[must_use] + pub fn only_minor_release(&self) -> Self { + Self::new(self.release().iter().take(2).copied()) + } + /// Return the version with any segments apart from the release removed, with trailing zeroes /// trimmed. #[inline] diff --git a/crates/uv-pep440/src/version_specifier.rs b/crates/uv-pep440/src/version_specifier.rs index d57ace8a5..aff917179 100644 --- a/crates/uv-pep440/src/version_specifier.rs +++ b/crates/uv-pep440/src/version_specifier.rs @@ -394,6 +394,15 @@ impl VersionSpecifier { } } + /// Remove all parts of the version beyond the minor segment of the release. + #[must_use] + pub fn only_minor_release(&self) -> Self { + Self { + operator: self.operator, + version: self.version.only_minor_release(), + } + } + /// `==` pub fn equals_version(version: Version) -> Self { Self { diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 04bc874b1..9c922e90e 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -37,7 +37,7 @@ use crate::virtualenv::{ }; #[cfg(windows)] use crate::windows_registry::{WindowsPython, registry_pythons}; -use crate::{BrokenSymlink, Interpreter, PythonInstallationKey, PythonVersion}; +use crate::{BrokenSymlink, Interpreter, PythonVersion}; /// A request to find a Python installation. /// @@ -2457,9 +2457,26 @@ impl fmt::Display for ExecutableName { } impl VersionRequest { - /// Derive a [`VersionRequest::MajorMinor`] from a [`PythonInstallationKey`] - pub fn major_minor_request_from_key(key: &PythonInstallationKey) -> Self { - Self::MajorMinor(key.major, key.minor, key.variant) + /// Drop any patch or prerelease information from the version request. + #[must_use] + pub fn only_minor(self) -> Self { + match self { + Self::Any => self, + Self::Default => self, + Self::Range(specifiers, variant) => Self::Range( + specifiers + .into_iter() + .map(|s| s.only_minor_release()) + .collect(), + variant, + ), + Self::Major(..) => self, + Self::MajorMinor(..) => self, + Self::MajorMinorPatch(major, minor, _, variant) + | Self::MajorMinorPrerelease(major, minor, _, variant) => { + Self::MajorMinor(major, minor, variant) + } + } } /// Return possible executable names for the given version request. diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index 484276d1c..28f018973 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -411,6 +411,10 @@ impl PythonDownloadRequest { self.libc.as_ref() } + pub fn take_version(&mut self) -> Option { + self.version.take() + } + /// Iterate over all [`PythonDownload`]'s that match this request. pub fn iter_downloads<'a>( &'a self, diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 208007fdf..c581ccdc1 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -210,15 +210,19 @@ pub(crate) async fn install( let requests: Vec<_> = if targets.is_empty() { if upgrade { is_unspecified_upgrade = true; + // On upgrade, derive requests for all of the existing installations let mut minor_version_requests = IndexSet::::default(); for installation in &existing_installations { - let request = VersionRequest::major_minor_request_from_key(installation.key()); - if let Ok(request) = InstallRequest::new( - PythonRequest::Version(request), + let mut request = PythonDownloadRequest::from(installation); + // We should always have a version in the request from an existing installation + let version = request.take_version().unwrap(); + // Drop the patch and prerelease parts from the request + request = request.with_version(version.only_minor()); + let install_request = InstallRequest::new( + PythonRequest::Key(request), python_downloads_json_url.as_deref(), - ) { - minor_version_requests.insert(request); - } + )?; + minor_version_requests.insert(install_request); } minor_version_requests.into_iter().collect::>() } else { @@ -284,13 +288,14 @@ pub(crate) async fn install( .collect::>(); if upgrade - && requests.iter().any(|request| { + && 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" + "error: `uv python upgrade` only accepts minor versions, got: {}", + request.request.to_canonical_string() )?; return Ok(ExitStatus::Failure); } diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index ada7c727a..e71569a2f 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -1230,7 +1230,7 @@ fn python_upgrade_not_allowed() { ----- stdout ----- ----- stderr ----- - error: `uv python upgrade` only accepts minor versions + error: `uv python upgrade` only accepts minor versions, got: 3.13.0 "); // Request a pre-release upgrade @@ -1240,7 +1240,7 @@ fn python_upgrade_not_allowed() { ----- stdout ----- ----- stderr ----- - error: `uv python upgrade` only accepts minor versions + error: `uv python upgrade` only accepts minor versions, got: 3.14rc3 "); } diff --git a/crates/uv/tests/it/python_upgrade.rs b/crates/uv/tests/it/python_upgrade.rs index 9d0fb4f24..43b805fde 100644 --- a/crates/uv/tests/it/python_upgrade.rs +++ b/crates/uv/tests/it/python_upgrade.rs @@ -1,5 +1,6 @@ use crate::common::{TestContext, uv_snapshot}; use anyhow::Result; +use assert_cmd::assert::OutputAssertExt; use assert_fs::fixture::FileTouch; use assert_fs::prelude::PathChild; @@ -30,7 +31,7 @@ fn python_upgrade() { ----- stdout ----- ----- stderr ----- - error: `uv python upgrade` only accepts minor versions + error: `uv python upgrade` only accepts minor versions, got: 3.10.17 "); // Upgrade patch version @@ -737,3 +738,27 @@ fn python_upgrade_force_install() -> Result<()> { Ok(()) } + +#[test] +fn python_upgrade_implementation() { + let context = TestContext::new_with_versions(&[]) + .with_python_download_cache() + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_empty_python_install_mirror() + .with_managed_python_dirs(); + + // Install pypy + context.python_install().arg("pypy@3.11").assert().success(); + + // Run the upgrade, we should not install cpython + uv_snapshot!(context.filters(), context.python_upgrade(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv python upgrade` is experimental and may change without warning. Pass `--preview-features python-upgrade` to disable this warning + All versions already on latest supported patch release + "); +}