diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index bf4813b7b3241..2fc4ec4377231 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -295,15 +295,12 @@ async fn upgrade_tool( } }; - let environment = match installed_tools.get_environment(name, cache) { - Ok(Some(environment)) => environment, + // Get the existing environment, if it exists and is valid. + let existing_environment = match installed_tools.get_environment(name, cache) { + Ok(Some(environment)) => Some(environment), Ok(None) => { - let install_command = format!("uv tool install {name}"); - return Err(anyhow::anyhow!( - "`{}` is not installed; run `{}` to install", - name.cyan(), - install_command.green() - )); + debug!("Tool `{name}` environment has an invalid Python interpreter, will recreate"); + None } Err(_) => { let install_command = format!("uv tool install --force {name}"); @@ -343,49 +340,92 @@ async fn upgrade_tool( // Check if we need to create a new environment — if so, resolve it first, then // install the requested tool - let (environment, outcome) = if let Some(interpreter) = - interpreter.filter(|interpreter| !environment.environment().uses(interpreter)) - { - // If we're using a new interpreter, re-create the environment for each tool. - let resolution = resolve_environment( - spec.into(), - interpreter, - python_platform, - build_constraints.clone(), - &settings.resolver, - client_builder, - &state, - Box::new(SummaryResolveLogger), - concurrency, - cache, - workspace_cache, - printer, - preview, - ) - .await?; + let (environment, outcome) = if let Some(interpreter) = interpreter { + let needs_recreation = existing_environment + .as_ref() + .map(|environment| !environment.environment().uses(interpreter)) + .unwrap_or(true); + + if needs_recreation { + // If we're using a new interpreter, re-create the environment for each tool. + let resolution = resolve_environment( + spec.into(), + interpreter, + python_platform, + build_constraints.clone(), + &settings.resolver, + client_builder, + &state, + Box::new(SummaryResolveLogger), + concurrency, + cache, + workspace_cache, + printer, + preview, + ) + .await?; + + let environment = installed_tools.create_environment(name, interpreter.clone())?; + + let environment = sync_environment( + environment, + &resolution.into(), + Modifications::Exact, + build_constraints, + (&settings).into(), + client_builder, + &state, + Box::new(DefaultInstallLogger), + installer_metadata, + concurrency, + cache, + printer, + preview, + ) + .await?; - let environment = installed_tools.create_environment(name, interpreter.clone())?; + (environment, UpgradeOutcome::UpgradeEnvironment) + } else { + // The environment already uses the requested interpreter, so upgrade in place. + let environment = + existing_environment.expect("environment must exist if recreation is unnecessary"); + let EnvironmentUpdate { + environment, + changelog, + } = update_environment( + environment.into_environment(), + spec, + Modifications::Exact, + python_platform, + build_constraints, + ExtraBuildRequires::default(), + &settings, + client_builder, + &state, + Box::new(SummaryResolveLogger), + Box::new(UpgradeInstallLogger::new(name.clone())), + installer_metadata, + concurrency, + cache, + workspace_cache, + DryRun::Disabled, + printer, + preview, + ) + .await?; - let environment = sync_environment( - environment, - &resolution.into(), - Modifications::Exact, - build_constraints, - (&settings).into(), - client_builder, - &state, - Box::new(DefaultInstallLogger), - installer_metadata, - concurrency, - cache, - printer, - preview, - ) - .await?; + let outcome = if changelog.includes(name) { + UpgradeOutcome::UpgradeTool + } else if changelog.is_empty() { + UpgradeOutcome::NoOp + } else { + UpgradeOutcome::UpgradeDependencies + }; - (environment, UpgradeOutcome::UpgradeEnvironment) - } else { - // Otherwise, upgrade the existing environment. + (environment, outcome) + } + } else if let Some(environment) = existing_environment { + // Otherwise, upgrade the existing environment in place. // TODO(zanieb): Build the environment in the cache directory then copy into the tool // directory. let EnvironmentUpdate { @@ -422,6 +462,13 @@ async fn upgrade_tool( }; (environment, outcome) + } else { + let install_command = format!("uv tool install --force {name}"); + return Err(anyhow::anyhow!( + "`{}` environment is missing or has an invalid Python interpreter; run `{}` to reinstall", + name.cyan(), + install_command.green() + )); }; if matches!( diff --git a/crates/uv/tests/it/tool_upgrade.rs b/crates/uv/tests/it/tool_upgrade.rs index 2d42c0ab5da04..ad1eab6bf9754 100644 --- a/crates/uv/tests/it/tool_upgrade.rs +++ b/crates/uv/tests/it/tool_upgrade.rs @@ -851,6 +851,97 @@ fn tool_upgrade_python() { }); } +/// Regression test for . +#[test] +fn tool_upgrade_python_removed_interpreter() { + let context = uv_test::test_context_with_versions!(&["3.11", "3.12"]) + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + uv_snapshot!(context.filters(), context.tool_install() + .arg("babel==2.6.0") + .arg("--index-url") + .arg("https://test.pypi.org/simple/") + .arg("--python").arg("3.11") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + babel==2.6.0 + + pytz==2018.5 + Installed 1 executable: pybabel + "); + + let tool_root = tool_dir.child("babel"); + + #[cfg(unix)] + { + let tool_python = tool_root.child("bin").child("python"); + fs_err::remove_file(&tool_python).unwrap(); + fs_err::os::unix::fs::symlink(context.temp_dir.join("missing-python"), &tool_python) + .unwrap(); + } + + #[cfg(windows)] + { + use uv_fs::Simplified; + + let pyvenv_cfg = tool_root.child("pyvenv.cfg"); + let broken_home = context.temp_dir.join("missing-python"); + let contents = fs_err::read_to_string(&pyvenv_cfg).unwrap(); + let contents = contents + .lines() + .map(|line| { + if line.starts_with("home = ") { + format!("home = {}", broken_home.simplified_display()) + } else { + line.to_string() + } + }) + .collect::>() + .join("\n"); + fs_err::write(&pyvenv_cfg, format!("{contents}\n")).unwrap(); + } + + uv_snapshot!( + context.filters(), + context.tool_upgrade().arg("babel") + .arg("--python").arg("3.12") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + babel==2.6.0 + + pytz==2018.5 + Installed 1 executable: pybabel + Upgraded tool environment for `babel` to Python 3.12 + " + ); + + insta::with_settings!({ + filters => context.filters(), + }, { + let content = fs_err::read_to_string(tool_dir.join("babel").join("pyvenv.cfg")).unwrap(); + let lines: Vec<&str> = content.split('\n').collect(); + assert_snapshot!(lines[lines.len() - 3], @"version_info = 3.12.[X]"); + }); +} + #[test] fn tool_upgrade_python_with_all() { let context = uv_test::test_context_with_versions!(&["3.11", "3.12"])