Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 140 additions & 4 deletions crates/uv-python/src/managed.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -1011,7 +1036,7 @@ mod tests {
key,
url: None,
sha256: None,
build: None,
build: build.map(|s| Cow::Owned(s.to_owned())),
}
}

Expand All @@ -1024,6 +1049,7 @@ mod tests {
8,
None,
PythonVariant::Default,
None,
);

// Same patch version should not be an upgrade
Expand All @@ -1039,6 +1065,7 @@ mod tests {
8,
None,
PythonVariant::Default,
None,
);
let newer = create_test_installation(
ImplementationName::CPython,
Expand All @@ -1047,6 +1074,7 @@ mod tests {
9,
None,
PythonVariant::Default,
None,
);

// Newer patch version should be an upgrade
Expand All @@ -1064,6 +1092,7 @@ mod tests {
8,
None,
PythonVariant::Default,
None,
);
let py311 = create_test_installation(
ImplementationName::CPython,
Expand All @@ -1072,6 +1101,7 @@ mod tests {
0,
None,
PythonVariant::Default,
None,
);

// Different minor versions should not be upgrades
Expand All @@ -1088,6 +1118,7 @@ mod tests {
8,
None,
PythonVariant::Default,
None,
);
let pypy = create_test_installation(
ImplementationName::PyPy,
Expand All @@ -1096,6 +1127,7 @@ mod tests {
9,
None,
PythonVariant::Default,
None,
);

// Different implementations should not be upgrades
Expand All @@ -1112,6 +1144,7 @@ mod tests {
8,
None,
PythonVariant::Default,
None,
);
let freethreaded = create_test_installation(
ImplementationName::CPython,
Expand All @@ -1120,6 +1153,7 @@ mod tests {
9,
None,
PythonVariant::Freethreaded,
None,
);

// Different variants should not be upgrades
Expand All @@ -1136,6 +1170,7 @@ mod tests {
8,
None,
PythonVariant::Default,
None,
);
let prerelease = create_test_installation(
ImplementationName::CPython,
Expand All @@ -1147,6 +1182,7 @@ mod tests {
number: 1,
}),
PythonVariant::Default,
None,
);

// A stable version is an upgrade from prerelease
Expand All @@ -1168,6 +1204,7 @@ mod tests {
number: 1,
}),
PythonVariant::Default,
None,
);
let alpha2 = create_test_installation(
ImplementationName::CPython,
Expand All @@ -1179,6 +1216,7 @@ mod tests {
number: 2,
}),
PythonVariant::Default,
None,
);

// Later prerelease should be an upgrade
Expand All @@ -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));
}
}
65 changes: 51 additions & 14 deletions crates/uv/src/commands/python/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
}
Loading
Loading