diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index d63d8d63d..8c7e6634c 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -24,7 +24,7 @@ use uv_requirements::RequirementsSpecification; use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint}; use uv_warnings::warn_user_once; -use crate::commands::project::update_environment; +use crate::commands::project::{resolve_environment, sync_environment, update_environment}; use crate::commands::tool::common::resolve_requirements; use crate::commands::{ExitStatus, SharedState}; use crate::printer::Printer; @@ -195,33 +195,62 @@ pub(crate) async fn install( // entrypoints (without `--force`). let reinstall_entry_points = existing_tool_receipt.is_some(); + // Resolve the requirements. + let state = SharedState::default(); + let spec = RequirementsSpecification::from_requirements(requirements.clone()); + // TODO(zanieb): Build the environment in the cache directory then copy into the tool directory. // This lets us confirm the environment is valid before removing an existing install. However, // 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 { - environment + update_environment( + environment, + spec, + &settings, + &state, + preview, + connectivity, + concurrency, + native_tls, + cache, + printer, + ) + .await? } else { - // TODO(charlie): Resolve, then create the environment, then install. This ensures that - // we don't nuke the environment if the resolution fails. - installed_tools.create_environment(&from.name, interpreter)? - }; + // If we're creating a new environment, ensure that we can resolve the requirements prior + // to removing any existing tools. + let resolution = resolve_environment( + &interpreter, + spec, + settings.as_ref().into(), + &state, + preview, + connectivity, + concurrency, + native_tls, + cache, + printer, + ) + .await?; - // Install the ephemeral requirements. - let spec = RequirementsSpecification::from_requirements(requirements.clone()); - let environment = update_environment( - environment, - spec, - &settings, - &state, - preview, - connectivity, - concurrency, - native_tls, - cache, - printer, - ) - .await?; + let environment = installed_tools.create_environment(&from.name, interpreter)?; + + // Sync the environment with the resolved requirements. + sync_environment( + environment, + &resolution.into(), + settings.as_ref().into(), + &state, + preview, + connectivity, + concurrency, + native_tls, + cache, + printer, + ) + .await? + }; let site_packages = SitePackages::from_environment(&environment)?; let installed = site_packages.get_packages(&from.name); diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index 1aa11a258..7e0261dd2 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -1398,3 +1398,67 @@ fn tool_install_python_request() { Installed: black, blackd "###); } + +/// Test preserving a tool environment when new but incompatible requirements are requested. +#[test] +fn tool_install_preserve_environment() { + let context = TestContext::new("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"); + + // Install `black`. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black==24.1.1") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", 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 [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + black==24.1.1 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed: black, blackd + "###); + + // Install `black`, but with an incompatible requirement. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black==24.1.1") + .arg("--with") + .arg("packaging==0.0.1") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + error: Because black==24.1.1 depends on packaging>=22.0 and you require black==24.1.1, we can conclude that you require packaging>=22.0. + And because you require packaging==0.0.1, we can conclude that the requirements are unsatisfiable. + "###); + + // Install `black`. The tool should already be installed, since we didn't remove the environment. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black==24.1.1") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + Tool `black==24.1.1` is already installed + "###); +}