diff --git a/crates/uv/src/commands/tool/common.rs b/crates/uv/src/commands/tool/common.rs index 39b6ac0913436..dffca9abcce33 100644 --- a/crates/uv/src/commands/tool/common.rs +++ b/crates/uv/src/commands/tool/common.rs @@ -123,11 +123,16 @@ pub(super) fn matching_packages(name: &str, site_packages: &SitePackages) -> Vec /// Remove any entrypoints attached to the [`Tool`]. pub(crate) fn remove_entrypoints(tool: &Tool) { - for executable in tool - .entrypoints() - .iter() - .map(|entrypoint| &entrypoint.install_path) - { + remove_entrypoint_paths( + tool.entrypoints() + .iter() + .map(|entrypoint| entrypoint.install_path.as_path()), + ); +} + +/// Remove the entrypoints at the given paths. +fn remove_entrypoint_paths<'a>(entrypoints: impl IntoIterator) { + for executable in entrypoints { debug!("Removing executable: `{}`", executable.simplified_display()); if let Err(err) = fs_err::remove_file(executable) { warn!( @@ -372,7 +377,7 @@ pub(crate) fn finalize_tool_install( executable_directory.user_display() ); - let mut installed_entrypoints = Vec::new(); + let mut installed_entrypoints: Vec = Vec::new(); let site_packages = SitePackages::from_environment(environment)?; let ordered_packages = entrypoints // Install dependencies first @@ -391,9 +396,29 @@ pub(crate) fn finalize_tool_install( } let installed = site_packages.get_packages(package); - let dist = installed - .first() - .context("Expected at least one requirement")?; + let Some(dist) = installed.first() else { + if package != name { + bail!("Expected package `{package}` to be installed"); + } + + writeln!( + printer.stdout(), + "No executables are provided by package `{}`; removing tool", + package.cyan() + )?; + remove_entrypoint_paths( + installed_entrypoints + .iter() + .map(|entrypoint| entrypoint.install_path.as_path()), + ); + installed_tools.remove_environment(name)?; + + return Err(NoExecutablesError::Root { + package: package.clone(), + matching_dependency_packages: Vec::new(), + } + .into()); + }; let dist_entrypoints = entrypoint_paths(&site_packages, dist.name(), dist.version())?; // Determine the entry points targets. Use a sorted collection for deterministic output. @@ -446,6 +471,11 @@ pub(crate) fn finalize_tool_install( )?; // Clean up the environment we just created. + remove_entrypoint_paths( + installed_entrypoints + .iter() + .map(|entrypoint| entrypoint.install_path.as_path()), + ); installed_tools.remove_environment(name)?; return Err(err.into()); @@ -459,6 +489,11 @@ pub(crate) fn finalize_tool_install( .peekable(); if existing_entrypoints.peek().is_some() { // Clean up the environment we just created + remove_entrypoint_paths( + installed_entrypoints + .iter() + .map(|entrypoint| entrypoint.install_path.as_path()), + ); installed_tools.remove_environment(name)?; let existing_entrypoints = existing_entrypoints diff --git a/crates/uv/tests/tool/tool_install.rs b/crates/uv/tests/tool/tool_install.rs index 7684ec5c26d63..beb54d771ed31 100644 --- a/crates/uv/tests/tool/tool_install.rs +++ b/crates/uv/tests/tool/tool_install.rs @@ -3297,6 +3297,59 @@ fn tool_install_no_entrypoints() { .assert(predicate::path::missing()); } +/// Test that a failed tool installation removes entrypoints installed from additional packages. +#[test] +fn tool_install_failure_removes_additional_entrypoints() -> Result<()> { + let context = uv_test::test_context!("3.12").with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + context.temp_dir.child("uv.toml").write_str( + r#" + exclude-dependencies = ["iniconfig"] + "#, + )?; + + uv_snapshot!(context.filters(), context.tool_install() + .arg("--with-executables-from") + .arg("black") + .arg("iniconfig") + .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: false + exit_code: 2 + ----- stdout ----- + No executables are provided by package `iniconfig`; removing tool + + ----- stderr ----- + Resolved 7 packages in [TIME] + Prepared 7 packages in [TIME] + Installed 7 packages in [TIME] + + black==24.3.0 + + click==8.1.7 + + iniconfig==2.0.0 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed 2 executables from `black`: black, blackd + error: Failed to install entrypoints for `iniconfig` + "); + + tool_dir + .child("iniconfig") + .assert(predicate::path::missing()); + bin_dir + .child(format!("black{}", std::env::consts::EXE_SUFFIX)) + .assert(predicate::path::missing()); + bin_dir + .child(format!("blackd{}", std::env::consts::EXE_SUFFIX)) + .assert(predicate::path::missing()); + + Ok(()) +} + #[test] fn tool_install_no_binary_package_env_var() { let context = uv_test::test_context!("3.12").with_filtered_exe_suffix();