Skip to content
Open
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
143 changes: 95 additions & 48 deletions crates/uv/src/commands/tool/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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!(
Expand Down
91 changes: 91 additions & 0 deletions crates/uv/tests/it/tool_upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,97 @@ fn tool_upgrade_python() {
});
}

/// Regression test for <https://github.com/astral-sh/uv/issues/17907>.
#[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::<Vec<_>>()
.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"])
Expand Down
Loading