Upgrade based on outdated build versions in uv python upgrade (#17653)

This commit is contained in:
Zanie Blue
2026-01-26 15:15:30 -06:00
committed by GitHub
parent ca1ab24477
commit 4e13f082a8
4 changed files with 311 additions and 19 deletions
+140 -4
View File
@@ -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::<u64>(), b.parse::<u64>()) {
(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<Prerelease>,
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));
}
}
+51 -14
View File
@@ -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,
}
}
+60
View File
@@ -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(&[])
+60 -1
View File
@@ -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
");
}