Upgrade based on outdated build versions in uv python upgrade (#17653)
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(&[])
|
||||
|
||||
@@ -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
|
||||
");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user