diff --git a/Cargo.lock b/Cargo.lock index f074e9be3e6f..6d4198b3a98d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5027,6 +5027,7 @@ dependencies = [ "toml", "toml_edit", "tracing", + "uv-cache", "uv-fs", "uv-state", "uv-toolchain", diff --git a/crates/uv-tool/Cargo.toml b/crates/uv-tool/Cargo.toml index e565b63990f5..0fa2adefe4ab 100644 --- a/crates/uv-tool/Cargo.toml +++ b/crates/uv-tool/Cargo.toml @@ -21,6 +21,7 @@ uv-virtualenv = { workspace = true } uv-toolchain = { workspace = true } install-wheel-rs = { workspace = true } pep440_rs = { workspace = true } +uv-cache = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs index e08cb8718ac8..85099ac11ade 100644 --- a/crates/uv-tool/src/lib.rs +++ b/crates/uv-tool/src/lib.rs @@ -8,6 +8,7 @@ use std::io::{self, Write}; use std::path::{Path, PathBuf}; use thiserror::Error; use tracing::debug; +use uv_cache::Cache; use uv_fs::{LockedFile, Simplified}; use uv_toolchain::{Interpreter, PythonEnvironment}; @@ -21,9 +22,9 @@ pub enum Error { #[error(transparent)] IO(#[from] io::Error), // TODO(zanieb): Improve the error handling here - #[error("Failed to update `tools.toml` at {0}")] + #[error("Failed to update `tools.toml` metadata at {0}")] TomlEdit(PathBuf, #[source] tools_toml::Error), - #[error("Failed to read `tools.toml` at {0}")] + #[error("Failed to read `tools.toml` metadata at {0}")] TomlRead(PathBuf, #[source] Box), #[error(transparent)] VirtualEnvError(#[from] uv_virtualenv::Error), @@ -33,6 +34,8 @@ pub enum Error { DistInfoMissing(String, PathBuf), #[error("Failed to find a directory for executables")] NoExecutableDirectory, + #[error(transparent)] + EnvironmentError(#[from] uv_toolchain::Error), } /// A collection of uv-managed tools installed on the current system. @@ -121,16 +124,26 @@ impl InstalledTools { Ok(()) } - pub fn create_environment( + pub fn environment( &self, name: &str, + remove_existing: bool, interpreter: Interpreter, + cache: &Cache, ) -> Result { let _lock = self.acquire_lock(); let environment_path = self.root.join(name); + if !remove_existing && environment_path.exists() { + debug!( + "Using existing environment for tool `{name}` at `{}`.", + environment_path.user_display() + ); + return Ok(PythonEnvironment::from_root(environment_path, cache)?); + } + debug!( - "Creating environment for tool `{name}` at {}.", + "Creating environment for tool `{name}` at `{}`.", environment_path.user_display() ); diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 82d068b78466..abba0d9b2d09 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -9,10 +9,10 @@ use itertools::Itertools; use pep508_rs::Requirement; use pypi_types::VerbatimParsedUrl; -use tracing::debug; +use tracing::{debug, trace}; use uv_cache::Cache; use uv_client::Connectivity; -use uv_configuration::{Concurrency, PreviewMode}; +use uv_configuration::{Concurrency, PreviewMode, Reinstall}; #[cfg(unix)] use uv_fs::replace_symlink; use uv_fs::Simplified; @@ -47,29 +47,44 @@ pub(crate) async fn install( if preview.is_disabled() { warn_user!("`uv tool install` is experimental and may change without warning."); } + let from = from.unwrap_or(name.clone()); let installed_tools = InstalledTools::from_settings()?; + // TODO(zanieb): Figure out the interface here, do we infer the name or do we match the `run --from` interface? + let from = Requirement::::from_str(&from)?; + let existing_tool_entry = installed_tools.find_tool_entry(&name)?; // TODO(zanieb): Automatically replace an existing tool if the request differs - if installed_tools.find_tool_entry(&name)?.is_some() { + let reinstall_entry_points = if existing_tool_entry.is_some() { if force { debug!("Replacing existing tool due to `--force` flag."); + false } else { - writeln!(printer.stderr(), "Tool `{name}` is already installed.")?; - return Ok(ExitStatus::Failure); + match settings.reinstall { + Reinstall::All => { + debug!("Replacing existing tool due to `--reinstall` flag."); + true + } + // Do not replace the entry points unless the tool is explicitly requested + Reinstall::Packages(ref packages) => packages.contains(&from.name), + // If not reinstalling... then we're done + Reinstall::None => { + writeln!(printer.stderr(), "Tool `{name}` is already installed.")?; + return Ok(ExitStatus::Failure); + } + } } - } - - // TODO(zanieb): Figure out the interface here, do we infer the name or do we match the `run --from` interface? - let from = from.unwrap_or(name.clone()); + } else { + false + }; - let requirements = [Requirement::from_str(&from)] + let requirements = [Requirement::from_str(from.name.as_ref())] .into_iter() .chain(with.iter().map(|name| Requirement::from_str(name))) .collect::>, _>>()?; // TODO(zanieb): Duplicative with the above parsing but needed for `update_environment` - let requirements_sources = [RequirementsSource::from_package(from.clone())] + let requirements_sources = [RequirementsSource::from_package(from.name.to_string())] .into_iter() .chain(with.into_iter().map(RequirementsSource::from_package)) .collect::>(); @@ -93,7 +108,13 @@ pub(crate) async fn install( // 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 - let environment = installed_tools.create_environment(&name, interpreter)?; + let environment = installed_tools.environment( + &name, + // Do not remove the existing environment if we're reinstalling a subset of packages + !matches!(settings.reinstall, Reinstall::Packages(_)), + interpreter, + cache, + )?; // Install the ephemeral requirements. let environment = update_environment( @@ -115,13 +136,23 @@ pub(crate) async fn install( bail!("Expected at least one requirement") }; + // Exit early if we're not supposed to be reinstalling entry points + // e.g. `--reinstall-package` was used for some dependency + if existing_tool_entry.is_some() && !reinstall_entry_points { + writeln!(printer.stderr(), "Updated environment for tool `{name}`")?; + return Ok(ExitStatus::Success); + } + // Find a suitable path to install into // TODO(zanieb): Warn if this directory is not on the PATH let executable_directory = find_executable_directory()?; fs_err::create_dir_all(&executable_directory) .context("Failed to create executable directory")?; - debug!("Installing into {}", executable_directory.user_display()); + debug!( + "Installing tool entry points into {}", + executable_directory.user_display() + ); let entrypoints = entrypoint_paths( &environment, @@ -130,6 +161,7 @@ pub(crate) async fn install( )?; // Determine the entry points targets + // Use a sorted collection for deterministic output let targets = entrypoints .into_iter() .map(|(name, path)| { @@ -140,16 +172,19 @@ pub(crate) async fn install( ); (name, path, target) }) - .collect::>(); + .collect::>(); // Check if they exist, before installing let mut existing_targets = targets .iter() .filter(|(_, _, target)| target.exists()) .peekable(); - if force { + + // 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 { for (name, _, target) in existing_targets { - debug!("Removing existing install of `{name}`"); + debug!("Removing existing entry point `{name}`"); fs_err::remove_file(target)?; } } else if existing_targets.peek().is_some() { @@ -159,7 +194,7 @@ pub(crate) async fn install( let existing_targets = existing_targets // SAFETY: We know the target has a filename because we just constructed it above .map(|(_, _, target)| target.file_name().unwrap().to_string_lossy()) - .collect::>(); + .collect::>(); let (s, exists) = if existing_targets.len() == 1 { ("", "exists") } else { @@ -178,9 +213,13 @@ pub(crate) async fn install( replace_symlink(&path, &target).context("Failed to install entrypoint")?; #[cfg(windows)] fs_err::copy(&path, &target).context("Failed to install entrypoint")?; + writeln!(printer.stderr(), "Installed `{name}`")?; } - debug!("Adding `{name}` to {}", path.user_display()); + trace!( + "Tracking installed tool `{name}` in tool metadata at `{}`", + path.user_display() + ); let installed_tools = installed_tools.init()?; installed_tools.add_tool_entry(&name, &tool)?; diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index d2c27371a0b7..b9f1140d3944 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -39,6 +39,8 @@ fn tool_install() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + Installed `black` + Installed `blackd` "###); tool_dir.child("black").assert(predicate::path::is_dir()); @@ -109,6 +111,7 @@ fn tool_install() { + jinja2==3.1.3 + markupsafe==2.1.5 + werkzeug==3.0.1 + Installed `flask` "###); tool_dir.child("flask").assert(predicate::path::is_dir()); @@ -156,15 +159,22 @@ fn tool_install() { }); } -/// Test installing a tool twice with `uv tool install` +/// Test installing and reinstalling an already installed tool #[test] -fn tool_install_twice() { +fn tool_install_already_installed() { let context = TestContext::new("3.12"); let tool_dir = context.temp_dir.child("tools"); let bin_dir = context.temp_dir.child("bin"); + // Drop resolved counts, they differ on Windows and are not relevant here + let filters = context + .filters() + .into_iter() + .chain([("Resolved [0-9] packages", "Resolved [COUNT] packages")]) + .collect::>(); + // Install `black` - uv_snapshot!(context.filters(), context.tool_install() + uv_snapshot!(filters, context.tool_install() .arg("black") .env("UV_TOOL_DIR", tool_dir.as_os_str()) .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" @@ -174,7 +184,7 @@ fn tool_install_twice() { ----- stderr ----- warning: `uv tool install` is experimental and may change without warning. - Resolved 6 packages in [TIME] + Resolved [COUNT] packages in [TIME] Prepared 6 packages in [TIME] Installed 6 packages in [TIME] + black==24.3.0 @@ -183,6 +193,8 @@ fn tool_install_twice() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + Installed `black` + Installed `blackd` "###); tool_dir.child("black").assert(predicate::path::is_dir()); @@ -223,7 +235,7 @@ fn tool_install_twice() { }); // Install `black` again - uv_snapshot!(context.filters(), context.tool_install() + uv_snapshot!(filters, context.tool_install() .arg("black") .env("UV_TOOL_DIR", tool_dir.as_os_str()) .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" @@ -250,6 +262,76 @@ fn tool_install_twice() { black = { requirements = ["black"] } "###); }); + + // Install `black` again with the `--reinstall` flag + // We should recreate the entire environment and reinstall the entry points + uv_snapshot!(filters, context.tool_install() + .arg("black") + .arg("--reinstall") + .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 [COUNT] 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 `black` + Installed `blackd` + "###); + + // Install `black` again with `--reinstall-package` for `black` + // We should reinstall `black` in the environment and reinstall the entry points + uv_snapshot!(filters, context.tool_install() + .arg("black") + .arg("--reinstall-package") + .arg("black") + .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 [COUNT] packages in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - black==24.3.0 + + black==24.3.0 + Installed `black` + Installed `blackd` + "###); + + // Install `black` again with `--reinstall-package` for a dependency + // We should reinstall `click` in the environment but not reinstall the entry points + uv_snapshot!(filters, context.tool_install() + .arg("black") + .arg("--reinstall-package") + .arg("click") + .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 [COUNT] packages in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - click==8.1.7 + + click==8.1.7 + Updated environment for tool `black` + "###); } /// Test installing a tool when its entry point already exists @@ -262,14 +344,18 @@ fn tool_install_entry_point_exists() { let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX)); executable.touch().unwrap(); - // Drop executable suffixes for cross-platform snapshtos + // Drop executable suffixes for cross-platform snapshots + // Drop resolved counts, they differ on Windows and are not relevant here let filters = context .filters() .into_iter() - .chain([(std::env::consts::EXE_SUFFIX, "")]) + .chain([ + (std::env::consts::EXE_SUFFIX, ""), + ("Resolved [0-9] packages", "Resolved [COUNT] packages"), + ]) .collect::>(); - // Install `black` + // Attempt to install `black` uv_snapshot!(filters, context.tool_install() .arg("black") .env("UV_TOOL_DIR", tool_dir.as_os_str()) @@ -280,7 +366,7 @@ fn tool_install_entry_point_exists() { ----- stderr ----- warning: `uv tool install` is experimental and may change without warning. - Resolved 6 packages in [TIME] + Resolved [COUNT] packages in [TIME] Prepared 6 packages in [TIME] Installed 6 packages in [TIME] + black==24.3.0 @@ -306,6 +392,44 @@ fn tool_install_entry_point_exists() { }); + // Attempt to install `black` with the `--reinstall` flag + // Should have no effect + uv_snapshot!(filters, context.tool_install() + .arg("black") + .arg("--reinstall") + .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. + Resolved [COUNT] 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 + error: Entry point for tool already exists: black (use `--force` to overwrite) + "###); + + // We should not create a virtual environment + assert!(!tool_dir.child("black").exists()); + + // We should not write a tools entry + assert!(!tool_dir.join("tools.toml").exists()); + + insta::with_settings!({ + filters => context.filters(), + }, { + // Nor should we change the `black` entry point that exists + assert_snapshot!(fs_err::read_to_string(bin_dir.join("black")).unwrap(), @""); + + }); + // Test error message when multiple entry points exist bin_dir .child(format!("blackd{}", std::env::consts::EXE_SUFFIX)) @@ -321,7 +445,7 @@ fn tool_install_entry_point_exists() { ----- stderr ----- warning: `uv tool install` is experimental and may change without warning. - Resolved 6 packages in [TIME] + Resolved [COUNT] packages in [TIME] Installed 6 packages in [TIME] + black==24.3.0 + click==8.1.7 @@ -333,7 +457,7 @@ fn tool_install_entry_point_exists() { "###); // Install `black` with `--force` - uv_snapshot!(context.filters(), context.tool_install() + uv_snapshot!(filters, context.tool_install() .arg("black") .arg("--force") .env("UV_TOOL_DIR", tool_dir.as_os_str()) @@ -344,7 +468,7 @@ fn tool_install_entry_point_exists() { ----- stderr ----- warning: `uv tool install` is experimental and may change without warning. - Resolved 6 packages in [TIME] + Resolved [COUNT] packages in [TIME] Installed 6 packages in [TIME] + black==24.3.0 + click==8.1.7 @@ -352,6 +476,8 @@ fn tool_install_entry_point_exists() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + Installed `black` + Installed `blackd` "###); tool_dir.child("black").assert(predicate::path::is_dir()); @@ -435,6 +561,8 @@ fn tool_install_home() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + Installed `black` + Installed `blackd` "###); context @@ -470,6 +598,8 @@ fn tool_install_xdg_data_home() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + Installed `black` + Installed `blackd` "###); context @@ -505,6 +635,8 @@ fn tool_install_xdg_bin_home() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + Installed `black` + Installed `blackd` "###); bin_dir