Check for matching Python implementation during uv python upgrade (#16420)

Closes https://github.com/astral-sh/uv/issues/16416
This commit is contained in:
Zanie Blue 2025-10-23 10:48:34 -05:00 committed by GitHub
parent 00bf80bfda
commit 1fbc1c7ff4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 82 additions and 15 deletions

View file

@ -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]

View file

@ -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(),
}
}
/// `==<version>`
pub fn equals_version(version: Version) -> Self {
Self {

View file

@ -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.

View file

@ -411,6 +411,10 @@ impl PythonDownloadRequest {
self.libc.as_ref()
}
pub fn take_version(&mut self) -> Option<VersionRequest> {
self.version.take()
}
/// Iterate over all [`PythonDownload`]'s that match this request.
pub fn iter_downloads<'a>(
&'a self,

View file

@ -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::<InstallRequest>::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::<Vec<_>>()
} else {
@ -284,13 +288,14 @@ pub(crate) async fn install(
.collect::<IndexSet<_>>();
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);
}

View file

@ -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
");
}

View file

@ -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
");
}