diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 670f8366a202d..2876cd9becafd 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -1,6 +1,6 @@ use core::fmt; use std::borrow::Cow; -use std::cmp::Reverse; +use std::cmp::{Ordering, Reverse}; use std::ffi::OsStr; use std::io::{self, Write}; #[cfg(windows)] @@ -111,6 +111,18 @@ pub enum Error { #[error(transparent)] MacOsDylib(#[from] macos_dylib::Error), } + +/// Compare two build version strings. +/// +/// Build versions are typically YYYYMMDD date strings. Comparison is done numerically +/// if both values parse as integers, otherwise falls back to lexicographic comparison. +pub fn compare_build_versions(a: &str, b: &str) -> Ordering { + match (a.parse::(), b.parse::()) { + (Ok(a_num), Ok(b_num)) => a_num.cmp(&b_num), + _ => a.cmp(b), + } +} + /// A collection of uv-managed Python installations installed on the current system. #[derive(Debug, Clone, Eq, PartialEq)] pub struct ManagedPythonInstallations { @@ -690,14 +702,26 @@ impl ManagedPythonInstallation { return false; } // If the patch versions are the same, we're handling a pre-release upgrade + // or a build version upgrade if self.key.patch == other.key.patch { return match (self.key.prerelease, other.key.prerelease) { // Require a newer pre-release, if present on both (Some(self_pre), Some(other_pre)) => self_pre > other_pre, // Allow upgrade from pre-release to stable (None, Some(_)) => true, - // Do not upgrade from pre-release to stable, or for matching versions - (_, None) => false, + // Do not upgrade from stable to pre-release + (Some(_), None) => false, + // For matching stable versions (same patch, no prerelease), check build version + (None, None) => match (self.build.as_deref(), other.build.as_deref()) { + // Download has build, installation doesn't -> upgrade (legacy) + (Some(_), None) => true, + // Both have build, compare them + (Some(self_build), Some(other_build)) => { + compare_build_versions(self_build, other_build) == Ordering::Greater + } + // Download doesn't have build -> no upgrade + (None, _) => false, + }, }; } // Require a newer patch version @@ -995,6 +1019,7 @@ mod tests { patch: u8, prerelease: Option, variant: PythonVariant, + build: Option<&str>, ) -> ManagedPythonInstallation { let platform = Platform::from_str("linux-x86_64-gnu").unwrap(); let key = PythonInstallationKey::new( @@ -1011,7 +1036,7 @@ mod tests { key, url: None, sha256: None, - build: None, + build: build.map(|s| Cow::Owned(s.to_owned())), } } @@ -1024,6 +1049,7 @@ mod tests { 8, None, PythonVariant::Default, + None, ); // Same patch version should not be an upgrade @@ -1039,6 +1065,7 @@ mod tests { 8, None, PythonVariant::Default, + None, ); let newer = create_test_installation( ImplementationName::CPython, @@ -1047,6 +1074,7 @@ mod tests { 9, None, PythonVariant::Default, + None, ); // Newer patch version should be an upgrade @@ -1064,6 +1092,7 @@ mod tests { 8, None, PythonVariant::Default, + None, ); let py311 = create_test_installation( ImplementationName::CPython, @@ -1072,6 +1101,7 @@ mod tests { 0, None, PythonVariant::Default, + None, ); // Different minor versions should not be upgrades @@ -1088,6 +1118,7 @@ mod tests { 8, None, PythonVariant::Default, + None, ); let pypy = create_test_installation( ImplementationName::PyPy, @@ -1096,6 +1127,7 @@ mod tests { 9, None, PythonVariant::Default, + None, ); // Different implementations should not be upgrades @@ -1112,6 +1144,7 @@ mod tests { 8, None, PythonVariant::Default, + None, ); let freethreaded = create_test_installation( ImplementationName::CPython, @@ -1120,6 +1153,7 @@ mod tests { 9, None, PythonVariant::Freethreaded, + None, ); // Different variants should not be upgrades @@ -1136,6 +1170,7 @@ mod tests { 8, None, PythonVariant::Default, + None, ); let prerelease = create_test_installation( ImplementationName::CPython, @@ -1147,6 +1182,7 @@ mod tests { number: 1, }), PythonVariant::Default, + None, ); // A stable version is an upgrade from prerelease @@ -1168,6 +1204,7 @@ mod tests { number: 1, }), PythonVariant::Default, + None, ); let alpha2 = create_test_installation( ImplementationName::CPython, @@ -1179,6 +1216,7 @@ mod tests { number: 2, }), PythonVariant::Default, + None, ); // Later prerelease should be an upgrade @@ -1199,9 +1237,107 @@ mod tests { number: 1, }), PythonVariant::Default, + None, ); // Same prerelease should not be an upgrade assert!(!prerelease.is_upgrade_of(&prerelease)); } + + #[test] + fn test_is_upgrade_of_build_version() { + let older_build = create_test_installation( + ImplementationName::CPython, + 3, + 10, + 8, + None, + PythonVariant::Default, + Some("20240101"), + ); + let newer_build = create_test_installation( + ImplementationName::CPython, + 3, + 10, + 8, + None, + PythonVariant::Default, + Some("20240201"), + ); + + // Newer build version should be an upgrade + assert!(newer_build.is_upgrade_of(&older_build)); + // Older build version should not be an upgrade + assert!(!older_build.is_upgrade_of(&newer_build)); + } + + #[test] + fn test_is_upgrade_of_build_version_same() { + let installation = create_test_installation( + ImplementationName::CPython, + 3, + 10, + 8, + None, + PythonVariant::Default, + Some("20240101"), + ); + + // Same build version should not be an upgrade + assert!(!installation.is_upgrade_of(&installation)); + } + + #[test] + fn test_is_upgrade_of_build_with_legacy_installation() { + let legacy = create_test_installation( + ImplementationName::CPython, + 3, + 10, + 8, + None, + PythonVariant::Default, + None, + ); + let with_build = create_test_installation( + ImplementationName::CPython, + 3, + 10, + 8, + None, + PythonVariant::Default, + Some("20240101"), + ); + + // Installation with build should upgrade legacy installation without build + assert!(with_build.is_upgrade_of(&legacy)); + // Legacy installation should not upgrade installation with build + assert!(!legacy.is_upgrade_of(&with_build)); + } + + #[test] + fn test_is_upgrade_of_patch_takes_precedence_over_build() { + let older_patch_newer_build = create_test_installation( + ImplementationName::CPython, + 3, + 10, + 8, + None, + PythonVariant::Default, + Some("20240201"), + ); + let newer_patch_older_build = create_test_installation( + ImplementationName::CPython, + 3, + 10, + 9, + None, + PythonVariant::Default, + Some("20240101"), + ); + + // Newer patch version should be an upgrade regardless of build + assert!(newer_patch_older_build.is_upgrade_of(&older_patch_newer_build)); + // Older patch version should not be an upgrade even with newer build + assert!(!older_patch_newer_build.is_upgrade_of(&newer_patch_older_build)); + } } diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 2d1999c47fd85..91fb4a2761d21 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -8,7 +8,7 @@ use std::str::FromStr; use anyhow::{Context, Error, Result}; use futures::{StreamExt, join}; use indexmap::IndexSet; -use itertools::{Either, Itertools}; +use itertools::Itertools; use owo_colors::{AnsiColors, OwoColorize}; use rustc_hash::{FxHashMap, FxHashSet}; use tokio::sync::mpsc; @@ -26,7 +26,7 @@ use uv_python::downloads::{ }; use uv_python::managed::{ ManagedPythonInstallation, ManagedPythonInstallations, PythonMinorVersionLink, - create_link_to_executable, python_executable_dir, + compare_build_versions, create_link_to_executable, python_executable_dir, }; use uv_python::{ ImplementationName, Interpreter, PythonDownloads, PythonInstallationKey, @@ -524,24 +524,47 @@ async fn perform_install( (vec![], unsatisfied) } else { // 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 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() + let mut satisfied = Vec::new(); + let mut unsatisfied = Vec::new(); + + for request in &requests { + 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 (including build version). + if let Some(installation) = existing_installations + .iter() + .find(|inst| request.download.key() == inst.key()) + { + if matches_build(request.download.build(), installation.build()) { + debug!("Found `{}` for request `{}`", installation.key(), request); + satisfied.push(installation); + } else { + // Key matches but build version differs - track as existing for reinstall + debug!( + "Build version mismatch for `{}`, will upgrade", + installation.key() + ); + changelog.existing.insert(installation.key().clone()); + unsatisfied.push(Cow::Borrowed(request)); + } } else { - request.matches_installation(installation) + debug!("No installation found for request `{}`", request); + unsatisfied.push(Cow::Borrowed(request)); } - }) { + } else if let Some(installation) = existing_installations + .iter() + .find(|inst| request.matches_installation(inst)) + { debug!("Found `{}` for request `{}`", installation.key(), request); - Either::Left(installation) + satisfied.push(installation); } else { debug!("No installation found for request `{}`", request); - Either::Right(Cow::Borrowed(request)) + unsatisfied.push(Cow::Borrowed(request)); } - }) + } + + (satisfied, unsatisfied) }; // For all satisfied installs, bytecode compile them now before any future @@ -1324,3 +1347,17 @@ fn find_matching_bin_link<'a>( unreachable!("Only Unix and Windows are supported") } } + +/// Check if a download's build version matches an installation's build version. +/// +/// Returns `true` if the build versions match (no upgrade needed), `false` if an upgrade is needed. +fn matches_build(download_build: Option<&str>, installation_build: Option<&str>) -> bool { + match (download_build, installation_build) { + // Both have build, check if they match + (Some(d), Some(i)) => compare_build_versions(d, i) == std::cmp::Ordering::Equal, + // Legacy installation without BUILD file needs upgrade + (Some(_), None) => false, + // Download doesn't have build info, assume matches + (None, _) => true, + } +} diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index 429104deef421..001dec35f4029 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -15,6 +15,7 @@ use predicates::prelude::predicate; use tracing::debug; use uv_fs::Simplified; +use uv_python::managed::platform_key_from_env; use uv_static::EnvVars; use walkdir::WalkDir; @@ -4151,6 +4152,65 @@ fn python_install_compile_bytecode_upgrade() { "); } +#[test] +fn python_install_upgrade_build_version() { + let context: TestContext = TestContext::new_with_versions(&[]) + .with_python_download_cache() + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_managed_python_dirs(); + + // Install Python 3.12 + uv_snapshot!(context.filters(), context.python_install().arg("3.12"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.12.12 in [TIME] + + cpython-3.12.12-[PLATFORM] (python3.12) + "); + + // Should be a no-op when already installed at latest version + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade").arg("3.12"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Python 3.12 is already on the latest supported patch release + "); + + // Overwrite the BUILD file with an older build version + let installation_dir = context.temp_dir.child("managed").child(format!( + "cpython-3.12.12-{}", + platform_key_from_env().unwrap() + )); + let build_file = installation_dir.join("BUILD"); + fs_err::write(&build_file, "19000101").unwrap(); + + // Now upgrade should detect the outdated build version and reinstall + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade").arg("3.12"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.12.12 in [TIME] + ~ cpython-3.12.12-[PLATFORM] + "); + + // Should be a no-op again after upgrade + uv_snapshot!(context.filters(), context.python_install().arg("--upgrade").arg("3.12"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Python 3.12 is already on the latest supported patch release + "); +} + #[test] fn python_install_compile_bytecode_multiple() { let context: TestContext = TestContext::new_with_versions(&[]) diff --git a/crates/uv/tests/it/python_upgrade.rs b/crates/uv/tests/it/python_upgrade.rs index 78de90985a153..c392ce79dd3e9 100644 --- a/crates/uv/tests/it/python_upgrade.rs +++ b/crates/uv/tests/it/python_upgrade.rs @@ -3,7 +3,7 @@ use anyhow::Result; use assert_cmd::assert::OutputAssertExt; use assert_fs::fixture::FileTouch; use assert_fs::prelude::PathChild; - +use uv_python::managed::platform_key_from_env; use uv_static::EnvVars; #[test] @@ -772,3 +772,62 @@ fn python_upgrade_implementation() { All versions already on latest supported patch release "); } + +#[test] +fn python_upgrade_build_version() { + let context: TestContext = TestContext::new_with_versions(&[]) + .with_python_download_cache() + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_managed_python_dirs(); + + // Install Python 3.12 + uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.12.12 in [TIME] + + cpython-3.12.12-[PLATFORM] (python3.12) + "); + + // Should be a no-op when already installed at latest version + uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview").arg("3.12"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Python 3.12 is already on the latest supported patch release + "); + + // Overwrite the BUILD file with an older build version + let installation_dir = context.temp_dir.child("managed").child(format!( + "cpython-3.12.12-{}", + platform_key_from_env().unwrap() + )); + let build_file = installation_dir.join("BUILD"); + fs_err::write(&build_file, "19000101").unwrap(); + + // Now upgrade should detect the outdated build version and reinstall + uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview").arg("3.12"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.12.12 in [TIME] + ~ cpython-3.12.12-[PLATFORM] + "); + + // Should be a no-op again after upgrade + uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview").arg("3.12"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Python 3.12 is already on the latest supported patch release + "); +}