diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index 54b20d6c8..d26635f69 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -28,6 +28,7 @@ use crate::implementation::{ }; use crate::installation::PythonInstallationKey; use crate::libc::LibcDetectionError; +use crate::managed::ManagedPythonInstallation; use crate::platform::{self, Arch, Libc, Os}; use crate::{Interpreter, PythonRequest, PythonVersion, VersionRequest}; @@ -344,6 +345,23 @@ impl PythonDownloadRequest { } } +impl From<&ManagedPythonInstallation> for PythonDownloadRequest { + fn from(installation: &ManagedPythonInstallation) -> Self { + let key = installation.key(); + Self::new( + Some(VersionRequest::from(&key.version())), + match &key.implementation { + LenientImplementationName::Known(implementation) => Some(*implementation), + LenientImplementationName::Unknown(name) => unreachable!("Managed Python installations are expected to always have known implementation names, found {name}"), + }, + Some(key.arch), + Some(key.os), + Some(key.libc), + Some(key.prerelease.is_some()), + ) + } +} + impl Display for PythonDownloadRequest { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut parts = Vec::new(); diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 69aa4a5d8..d64d219bf 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fmt::Write; use std::io::ErrorKind; use std::path::{Path, PathBuf}; @@ -164,7 +165,12 @@ pub(crate) async fn install( .unwrap_or_else(|| { // If no version file is found and no requests were made is_default_install = true; - vec![PythonRequest::Default] + vec![if reinstall { + // On bare `--reinstall`, reinstall all Python versions + PythonRequest::Any + } else { + PythonRequest::Default + }] }) .into_iter() .map(InstallRequest::new) @@ -193,35 +199,76 @@ pub(crate) async fn install( // Find requests that are already satisfied let mut changelog = Changelog::default(); - let (satisfied, unsatisfied): (Vec<_>, Vec<_>) = requests.iter().partition_map(|request| { - if let Some(installation) = existing_installations - .iter() - .find(|installation| request.matches_installation(installation)) - { - changelog.existing.insert(installation.key().clone()); - if reinstall { - debug!( - "Ignoring match `{}` for request `{}` due to `--reinstall` flag", - installation.key().green(), - request.cyan() - ); + let (satisfied, unsatisfied): (Vec<_>, Vec<_>) = if reinstall { + // In the reinstall case, we want to iterate over all matching installations instead of + // stopping at the first match. - Either::Right(request) - } else { + let mut unsatisfied: Vec> = + Vec::with_capacity(existing_installations.len() + requests.len()); + + for request in &requests { + if existing_installations.is_empty() { + debug!("No installation found for request `{}`", request.cyan()); + unsatisfied.push(Cow::Borrowed(request)); + } + + for installation in existing_installations + .iter() + .filter(|installation| request.matches_installation(installation)) + { + changelog.existing.insert(installation.key().clone()); + if matches!(&request.request, &PythonRequest::Any) { + // Construct a install request matching the existing installation + match InstallRequest::new(PythonRequest::Key(installation.into())) { + Ok(request) => { + debug!("Will reinstall `{}`", installation.key().green()); + unsatisfied.push(Cow::Owned(request)); + } + Err(err) => { + // This shouldn't really happen, but maybe a new version of uv dropped + // support for a key we previously supported + warn_user!( + "Failed to create reinstall request for existing installation `{}`: {err}", + installation.key().green() + ); + } + } + } else { + // TODO(zanieb): This isn't really right! But we need `--upgrade` or similar + // to handle this case correctly without causing a breaking change. + + // If we have real requests, just ignore the existing installation + debug!( + "Ignoring match `{}` for request `{}` due to `--reinstall` flag", + installation.key().green(), + request.cyan() + ); + unsatisfied.push(Cow::Borrowed(request)); + break; + } + } + } + + (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| request.matches_installation(installation)) + { debug!( "Found `{}` for request `{}`", installation.key().green(), request.cyan(), ); - Either::Left(installation) + } else { + debug!("No installation found for request `{}`", request.cyan()); + Either::Right(Cow::Borrowed(request)) } - } else { - debug!("No installation found for request `{}`", request.cyan()); - - Either::Right(request) - } - }); + }) + }; // Check if Python downloads are banned if matches!(python_downloads, PythonDownloads::Never) && !unsatisfied.is_empty() { diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index 067f465c0..7d4402b1a 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -54,7 +54,7 @@ fn python_install() { "###); // You can opt-in to a reinstall - uv_snapshot!(context.filters(), context.python_install().arg("--reinstall"), @r###" + uv_snapshot!(context.filters(), context.python_install().arg("3.13").arg("--reinstall"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -91,6 +91,82 @@ fn python_install() { "###); } +#[test] +fn python_reinstall() { + let context: TestContext = TestContext::new_with_versions(&[]) + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_managed_python_dirs(); + + // Install a couple versions + uv_snapshot!(context.filters(), context.python_install().arg("3.12").arg("3.13"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed 2 versions in [TIME] + + cpython-3.12.8-[PLATFORM] + + cpython-3.13.1-[PLATFORM] + "###); + + // Reinstall a single version + uv_snapshot!(context.filters(), context.python_install().arg("3.13").arg("--reinstall"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.13.1 in [TIME] + ~ cpython-3.13.1-[PLATFORM] + "###); + + // Reinstall multiple versions + uv_snapshot!(context.filters(), context.python_install().arg("--reinstall"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed 2 versions in [TIME] + ~ cpython-3.12.8-[PLATFORM] + ~ cpython-3.13.1-[PLATFORM] + "###); +} + +#[test] +fn python_reinstall_patch() { + let context: TestContext = TestContext::new_with_versions(&[]) + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_managed_python_dirs(); + + // Install a couple patch versions + uv_snapshot!(context.filters(), context.python_install().arg("3.12.6").arg("3.12.7"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed 2 versions in [TIME] + + cpython-3.12.6-[PLATFORM] + + cpython-3.12.7-[PLATFORM] + "###); + + // Reinstall all "3.12" versions + // TODO(zanieb): This doesn't work today, because we need this to install the "latest" as there + // is no workflow for `--upgrade` yet + uv_snapshot!(context.filters(), context.python_install().arg("3.12").arg("--reinstall"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.12.8 in [TIME] + + cpython-3.12.8-[PLATFORM] + "###); +} + #[test] fn python_install_automatic() { let context: TestContext = TestContext::new_with_versions(&[])