Skip to content

Commit

Permalink
Remove lingering executables after failed installs (#5666)
Browse files Browse the repository at this point in the history
## Summary

This could still be made more robust, but it's not critical, since you
can always `--force`. It's good to handle this case, though, since we
have an explicit error for it.

Closes #5490.
  • Loading branch information
charliermarsh committed Jul 31, 2024
1 parent 4b8a127 commit f8e2d2f
Show file tree
Hide file tree
Showing 2 changed files with 177 additions and 8 deletions.
48 changes: 40 additions & 8 deletions crates/uv/src/commands/tool/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,10 @@ pub(crate) async fn install(
//
// (If we find existing entrypoints later on, and the tool _doesn't_ exist, we'll avoid removing
// the external tool's entrypoints (without `--force`).)
let (existing_tool_receipt, reinstall_entry_points) =
let (existing_tool_receipt, invalid_tool_receipt) =
match installed_tools.get_tool_receipt(&from.name) {
Ok(None) => (None, false),
Ok(Some(receipt)) => (Some(receipt), true),
Ok(Some(receipt)) => (Some(receipt), false),
Err(_) => {
// If the tool is not installed properly, remove the environment and continue.
match installed_tools.remove_environment(&from.name) {
Expand Down Expand Up @@ -276,7 +276,7 @@ pub(crate) async fn install(
// entrypoints always contain an absolute path to the relevant Python interpreter, which would
// be invalidated by moving the environment.
let environment = if let Some(environment) = existing_environment {
update_environment(
let environment = update_environment(
environment,
spec,
&settings,
Expand All @@ -288,7 +288,15 @@ pub(crate) async fn install(
cache,
printer,
)
.await?
.await?;

// At this point, we updated the existing environment, so we should remove any of its
// existing executables.
if let Some(existing_receipt) = existing_tool_receipt {
remove_entrypoints(&existing_receipt);
}

environment
} else {
// If we're creating a new environment, ensure that we can resolve the requirements prior
// to removing any existing tools.
Expand All @@ -308,6 +316,12 @@ pub(crate) async fn install(

let environment = installed_tools.create_environment(&from.name, interpreter)?;

// At this point, we removed any existing environment, so we should remove any of its
// executables.
if let Some(existing_receipt) = existing_tool_receipt {
remove_entrypoints(&existing_receipt);
}

// Sync the environment with the resolved requirements.
sync_environment(
environment,
Expand Down Expand Up @@ -370,8 +384,9 @@ pub(crate) async fn install(

hint_executable_from_dependency(&from, &environment, printer)?;

// Clean up the environment we just created
// Clean up the environment we just created.
installed_tools.remove_environment(&from.name)?;

return Ok(ExitStatus::Failure);
}

Expand All @@ -381,9 +396,9 @@ pub(crate) async fn install(
.filter(|(_, _, target_path)| target_path.exists())
.peekable();

// Note we use `reinstall_entry_points` here instead of `reinstall`; requesting reinstall
// will _not_ remove existing entry points when they are not managed by uv.
if force || reinstall_entry_points {
// Ignore any existing entrypoints if the user passed `--force`, or the existing recept was
// broken.
if force || invalid_tool_receipt {
for (name, _, target) in existing_entry_points {
debug!("Removing existing executable: `{name}`");
fs_err::remove_file(target)?;
Expand Down Expand Up @@ -478,6 +493,23 @@ pub(crate) async fn install(
Ok(ExitStatus::Success)
}

/// Remove any entrypoints attached to the [`Tool`].
fn remove_entrypoints(tool: &Tool) {
for executable in tool
.entrypoints()
.iter()
.map(|entrypoint| &entrypoint.install_path)
{
debug!("Removing executable: `{}`", executable.simplified_display());
if let Err(err) = fs_err::remove_file(executable) {
warn!(
"Failed to remove executable: `{}`: {err}",
executable.simplified_display()
);
}
}
}

/// Displays a hint if an executable matching the package name can be found in a dependency of the package.
fn hint_executable_from_dependency(
from: &Requirement,
Expand Down
137 changes: 137 additions & 0 deletions crates/uv/tests/tool_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,143 @@ fn tool_install_editable() {
});
}

/// Ensure that we remove any existing entrypoints upon error.
#[test]
fn tool_install_remove_on_empty() -> Result<()> {
let context = TestContext::new("3.12").with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");

// Request `black`. It should reinstall from the registry.
uv_snapshot!(context.filters(), context.tool_install()
.arg("black")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str())
.env("PATH", bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv tool install` is experimental and may change without warning
Resolved 6 packages in [TIME]
Prepared 6 packages in [TIME]
Installed 6 packages in [TIME]
+ black==24.3.0
+ click==8.1.7
+ mypy-extensions==1.0.0
+ packaging==24.0
+ pathspec==0.12.1
+ platformdirs==4.2.0
Installed 2 executables: black, blackd
"###);

insta::with_settings!({
filters => context.filters(),
}, {
// We should have a tool receipt
assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###"
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
]
"###);
});

// Install `black` as an editable package, but without any entrypoints.
let black = context.temp_dir.child("black");
fs_err::create_dir_all(black.path())?;

let pyproject_toml = black.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "black"
version = "0.1.0"
description = "Black without any entrypoints"
authors = []
dependencies = []
requires-python = ">=3.11,<3.13"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#
})?;

let src = black.child("src").child("black");
fs_err::create_dir_all(src.path())?;

let init = src.child("__init__.py");
init.touch()?;

uv_snapshot!(context.filters(), context.tool_install()
.arg("-e")
.arg(black.path())
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str())
.env("PATH", bin_dir.as_os_str()), @r###"
success: false
exit_code: 1
----- stdout -----
No executables are provided by `black`
----- stderr -----
warning: `uv tool install` is experimental and may change without warning
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Uninstalled 6 packages in [TIME]
Installed 1 package in [TIME]
- black==24.3.0
+ black==0.1.0 (from file://[TEMP_DIR]/black)
- click==8.1.7
- mypy-extensions==1.0.0
- packaging==24.0
- pathspec==0.12.1
- platformdirs==4.2.0
"###);

// Re-request `black`. It should reinstall, without requiring `--force`.
uv_snapshot!(context.filters(), context.tool_install()
.arg("black")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str())
.env("PATH", bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv tool install` is experimental and may change without warning
Resolved 6 packages in [TIME]
Installed 6 packages in [TIME]
+ black==24.3.0
+ click==8.1.7
+ mypy-extensions==1.0.0
+ packaging==24.0
+ pathspec==0.12.1
+ platformdirs==4.2.0
Installed 2 executables: black, blackd
"###);

insta::with_settings!({
filters => context.filters(),
}, {
// We should have a tool receipt
assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###"
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
]
"###);
});

Ok(())
}

/// Test an editable installation of a tool using `--from`.
#[test]
fn tool_install_editable_from() {
Expand Down

0 comments on commit f8e2d2f

Please sign in to comment.