diff --git a/Cargo.lock b/Cargo.lock index d316f5dbc216..d6ef3d10272e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5019,12 +5019,14 @@ dependencies = [ "dirs-sys", "fs-err", "install-wheel-rs", + "path-slash", "pep440_rs", "pep508_rs", "pypi-types", "serde", "thiserror", "toml", + "toml_edit", "tracing", "uv-cache", "uv-fs", diff --git a/crates/uv-tool/Cargo.toml b/crates/uv-tool/Cargo.toml index b2a3e17f1302..c828c9fac37e 100644 --- a/crates/uv-tool/Cargo.toml +++ b/crates/uv-tool/Cargo.toml @@ -13,20 +13,22 @@ license = { workspace = true } workspace = true [dependencies] -uv-fs = { workspace = true } -uv-state = { workspace = true } +install-wheel-rs = { workspace = true } +pep440_rs = { workspace = true } pep508_rs = { workspace = true } pypi-types = { workspace = true } -uv-virtualenv = { workspace = true } +uv-cache = { workspace = true } +uv-fs = { workspace = true } +uv-state = { workspace = true } uv-toolchain = { workspace = true } -install-wheel-rs = { workspace = true } -pep440_rs = { workspace = true } +uv-virtualenv = { workspace = true } uv-warnings = { workspace = true } -uv-cache = { workspace = true } -thiserror = { workspace = true } -tracing = { workspace = true } +dirs-sys = { workspace = true } fs-err = { workspace = true } +path-slash = { workspace = true } serde = { workspace = true } +thiserror = { workspace = true } toml = { workspace = true } -dirs-sys = { workspace = true } +toml_edit = { workspace = true } +tracing = { workspace = true } diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs index 5c8764ea3409..906b57f88066 100644 --- a/crates/uv-tool/src/lib.rs +++ b/crates/uv-tool/src/lib.rs @@ -14,7 +14,7 @@ use uv_toolchain::{Interpreter, PythonEnvironment}; use uv_warnings::warn_user_once; pub use receipt::ToolReceipt; -pub use tool::Tool; +pub use tool::{Tool, ToolEntrypoint}; use uv_state::{StateBucket, StateStore}; mod receipt; @@ -135,10 +135,9 @@ impl InstalledTools { path.user_display() ); - let doc = toml::to_string(&tool_receipt) - .map_err(|err| Error::ReceiptWrite(path.clone(), Box::new(err)))?; + let doc = tool_receipt.to_toml(); - // Save the modified `tools.toml`. + // Save the modified `uv-receipt.toml`. fs_err::write(&path, doc)?; Ok(()) diff --git a/crates/uv-tool/src/receipt.rs b/crates/uv-tool/src/receipt.rs index abf130a09f69..4b3d8318efc1 100644 --- a/crates/uv-tool/src/receipt.rs +++ b/crates/uv-tool/src/receipt.rs @@ -1,12 +1,12 @@ use std::path::Path; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use crate::Tool; /// A `uv-receipt.toml` file tracking the installation of a tool. #[allow(dead_code)] -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct ToolReceipt { pub(crate) tool: Tool, @@ -30,6 +30,16 @@ impl ToolReceipt { Err(err) => Err(err.into()), } } + + /// Returns the TOML representation of this receipt. + pub(crate) fn to_toml(&self) -> String { + // We construct a TOML document manually instead of going through Serde to enable + // the use of inline tables. + let mut doc = toml_edit::DocumentMut::new(); + doc.insert("tool", toml_edit::Item::Table(self.tool.to_toml())); + + doc.to_string() + } } // Ignore raw document in comparison. diff --git a/crates/uv-tool/src/tool.rs b/crates/uv-tool/src/tool.rs index f5d4e98dbbf2..eaee699fc4a8 100644 --- a/crates/uv-tool/src/tool.rs +++ b/crates/uv-tool/src/tool.rs @@ -1,14 +1,59 @@ +use std::path::PathBuf; + +use path_slash::PathBufExt; use pypi_types::VerbatimParsedUrl; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +use toml_edit::value; +use toml_edit::Array; +use toml_edit::Table; +use toml_edit::Value; /// A tool entry. #[allow(dead_code)] -#[derive(Debug, Clone, Serialize, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "kebab-case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct Tool { + // The requirements requested by the user during installation. requirements: Vec>, + /// The Python requested by the user during installation. python: Option, + // A mapping of entry point names to their metadata. + entrypoints: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct ToolEntrypoint { + name: String, + install_path: PathBuf, +} + +/// Format an array so that each element is on its own line and has a trailing comma. +/// +/// Example: +/// +/// ```toml +/// requirements = [ +/// "foo", +/// "bar", +/// ] +/// ``` +fn each_element_on_its_line_array(elements: impl Iterator>) -> Array { + let mut array = elements + .map(Into::into) + .map(|mut value| { + // Each dependency is on its own line and indented. + value.decor_mut().set_prefix("\n "); + value + }) + .collect::(); + // With a trailing comma, inserting another entry doesn't change the preceding line, + // reducing the diff noise. + array.set_trailing_comma(true); + // The line break between the last element's comma and the closing square bracket. + array.set_trailing("\n"); + array } impl Tool { @@ -16,10 +61,67 @@ impl Tool { pub fn new( requirements: Vec>, python: Option, + entrypoints: impl Iterator, ) -> Self { + let mut entrypoints: Vec<_> = entrypoints.collect(); + entrypoints.sort(); Self { requirements, python, + entrypoints, + } + } + + /// Returns the TOML table for this tool. + pub(crate) fn to_toml(&self) -> Table { + let mut table = Table::new(); + + table.insert("requirements", { + let requirements = match self.requirements.as_slice() { + [] => Array::new(), + [requirement] => Array::from_iter([Value::from(requirement.to_string())]), + requirements => each_element_on_its_line_array( + requirements + .iter() + .map(|requirement| Value::from(requirement.to_string())), + ), + }; + value(requirements) + }); + + if let Some(ref python) = self.python { + table.insert("python", value(python)); } + + table.insert("entrypoints", { + let entrypoints = each_element_on_its_line_array( + self.entrypoints + .iter() + .map(ToolEntrypoint::to_toml) + .map(toml_edit::Table::into_inline_table), + ); + value(entrypoints) + }); + + table + } +} + +impl ToolEntrypoint { + /// Create a new [`ToolEntrypoint`]. + pub fn new(name: String, install_path: PathBuf) -> Self { + Self { name, install_path } + } + + /// Returns the TOML table for this entrypoint. + pub(crate) fn to_toml(&self) -> Table { + let mut table = Table::new(); + table.insert("name", value(&self.name)); + table.insert( + "install-path", + // Use cross-platform slashes so the toml string type does not change + value(self.install_path.to_slash_lossy().to_string()), + ); + table } } diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index d7d67293f78b..b96a7a039fcd 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -18,7 +18,7 @@ use uv_fs::replace_symlink; use uv_fs::Simplified; use uv_installer::SitePackages; use uv_requirements::RequirementsSource; -use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool}; +use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint}; use uv_toolchain::{EnvironmentPreference, Toolchain, ToolchainPreference, ToolchainRequest}; use uv_warnings::warn_user_once; @@ -115,7 +115,6 @@ pub(crate) async fn install( let Some(from) = requirements.first().cloned() else { bail!("Expected at least one requirement") }; - let tool = Tool::new(requirements, python.clone()); let interpreter = Toolchain::find( &python @@ -176,7 +175,7 @@ pub(crate) async fn install( executable_directory.user_display() ); - let entrypoints = entrypoint_paths( + let entry_points = entrypoint_paths( &environment, installed_dist.name(), installed_dist.version(), @@ -184,68 +183,79 @@ pub(crate) async fn install( // Determine the entry points targets // Use a sorted collection for deterministic output - let targets = entrypoints + let target_entry_points = entry_points .into_iter() - .map(|(name, path)| { - let target = executable_directory.join( - path.file_name() + .map(|(name, source_path)| { + let target_path = executable_directory.join( + source_path + .file_name() .map(std::borrow::ToOwned::to_owned) .unwrap_or_else(|| OsString::from(name.clone())), ); - (name, path, target) + (name, source_path, target_path) }) .collect::>(); // Check if they exist, before installing - let mut existing_targets = targets + let mut existing_entry_points = target_entry_points .iter() - .filter(|(_, _, target)| target.exists()) + .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 { - for (name, _, target) in existing_targets { + for (name, _, target) in existing_entry_points { debug!("Removing existing entry point `{name}`"); fs_err::remove_file(target)?; } - } else if existing_targets.peek().is_some() { + } else if existing_entry_points.peek().is_some() { // Clean up the environment we just created installed_tools.remove_environment(&name)?; - let existing_targets = existing_targets + let existing_entry_points = existing_entry_points // SAFETY: We know the target has a filename because we just constructed it above .map(|(_, _, target)| target.file_name().unwrap().to_string_lossy()) .collect::>(); - let (s, exists) = if existing_targets.len() == 1 { + let (s, exists) = if existing_entry_points.len() == 1 { ("", "exists") } else { ("s", "exist") }; bail!( "Entry point{s} for tool already {exists}: {} (use `--force` to overwrite)", - existing_targets.iter().join(", ") + existing_entry_points.iter().join(", ") ) } // TODO(zanieb): Handle the case where there are no entrypoints - for (name, path, target) in &targets { + for (name, source_path, target_path) in &target_entry_points { debug!("Installing `{name}`"); #[cfg(unix)] - replace_symlink(path, target).context("Failed to install entrypoint")?; + replace_symlink(source_path, target_path).context("Failed to install entrypoint")?; #[cfg(windows)] - fs_err::copy(path, target).context("Failed to install entrypoint")?; + fs_err::copy(source_path, target_path).context("Failed to install entrypoint")?; } - debug!("Adding receipt for tool `{name}`",); - let installed_tools = installed_tools.init()?; - installed_tools.add_tool_receipt(&name, tool)?; - writeln!( printer.stderr(), "Installed: {}", - targets.iter().map(|(name, _, _)| name).join(", ") + target_entry_points + .iter() + .map(|(name, _, _)| name) + .join(", ") )?; + debug!("Adding receipt for tool `{name}`",); + let installed_tools = installed_tools.init()?; + let tool = Tool::new( + requirements, + python, + target_entry_points + .into_iter() + .map(|(name, _, target_path)| ToolEntrypoint::new(name, target_path)), + ); + installed_tools.add_tool_receipt(&name, tool)?; + Ok(ExitStatus::Success) } diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index 18c2e945e39d..4154cb1fea57 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -15,7 +15,9 @@ mod common; /// Test installing a tool with `uv tool install` #[test] fn tool_install() { - let context = TestContext::new("3.12").with_filtered_counts(); + 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"); @@ -77,6 +79,10 @@ fn tool_install() { assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" [tool] requirements = ["black"] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] "###); }); @@ -154,6 +160,9 @@ fn tool_install() { assert_snapshot!(fs_err::read_to_string(tool_dir.join("flask").join("uv-receipt.toml")).unwrap(), @r###" [tool] requirements = ["flask"] + entrypoints = [ + { name = "flask", install-path = "[TEMP_DIR]/bin/flask" }, + ] "###); }); } @@ -161,7 +170,7 @@ fn tool_install() { /// Test installing a tool at a version #[test] fn tool_install_version() { - let context = TestContext::new("3.12"); + 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"); @@ -223,6 +232,10 @@ fn tool_install_version() { assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" [tool] requirements = ["black==24.2.0"] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] "###); }); @@ -240,7 +253,7 @@ fn tool_install_version() { /// Test installing a tool with `uv tool install --from` #[test] fn tool_install_from() { - let context = TestContext::new("3.12"); + 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"); @@ -305,7 +318,9 @@ fn tool_install_from() { /// Test installing and reinstalling an already installed tool #[test] fn tool_install_already_installed() { - let context = TestContext::new("3.12").with_filtered_counts(); + 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"); @@ -367,6 +382,10 @@ fn tool_install_already_installed() { assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" [tool] requirements = ["black"] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] "###); }); @@ -396,6 +415,10 @@ fn tool_install_already_installed() { assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" [tool] requirements = ["black"] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] "###); }); @@ -613,6 +636,10 @@ fn tool_install_entry_point_exists() { assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" [tool] requirements = ["black"] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] "###); }); @@ -642,6 +669,10 @@ fn tool_install_entry_point_exists() { assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" [tool] requirements = ["black"] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] "###); }); @@ -662,7 +693,7 @@ fn tool_install_entry_point_exists() { #[cfg(unix)] #[test] fn tool_install_home() { - let context = TestContext::new("3.12"); + let context = TestContext::new("3.12").with_filtered_exe_suffix(); let tool_dir = context.temp_dir.child("tools"); // Install `black` @@ -694,7 +725,7 @@ fn tool_install_home() { /// Test `uv tool install` when the bin directory is inferred from `$XDG_DATA_HOME` #[test] fn tool_install_xdg_data_home() { - let context = TestContext::new("3.12"); + let context = TestContext::new("3.12").with_filtered_exe_suffix(); let tool_dir = context.temp_dir.child("tools"); let data_home = context.temp_dir.child("data/home"); @@ -730,7 +761,7 @@ fn tool_install_xdg_data_home() { /// Test `uv tool install` when the bin directory is set by `$XDG_BIN_HOME` #[test] fn tool_install_xdg_bin_home() { - let context = TestContext::new("3.12"); + 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");