diff --git a/Cargo.lock b/Cargo.lock index 6d4198b3a..46f2c3716 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5025,7 +5025,6 @@ dependencies = [ "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 0fa2adefe..4eae2f0b7 100644 --- a/crates/uv-tool/Cargo.toml +++ b/crates/uv-tool/Cargo.toml @@ -28,5 +28,4 @@ tracing = { workspace = true } fs-err = { workspace = true } serde = { workspace = true } toml = { workspace = true } -toml_edit = { workspace = true } dirs-sys = { workspace = true } diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs index 85099ac11..ee8f6b96d 100644 --- a/crates/uv-tool/src/lib.rs +++ b/crates/uv-tool/src/lib.rs @@ -12,20 +12,21 @@ use uv_cache::Cache; use uv_fs::{LockedFile, Simplified}; use uv_toolchain::{Interpreter, PythonEnvironment}; -pub use tools_toml::{Tool, ToolsToml, ToolsTomlMut}; +pub use receipt::ToolReceipt; +pub use tool::Tool; use uv_state::{StateBucket, StateStore}; -mod tools_toml; +mod receipt; +mod tool; #[derive(Error, Debug)] pub enum Error { #[error(transparent)] IO(#[from] io::Error), - // TODO(zanieb): Improve the error handling here - #[error("Failed to update `tools.toml` metadata at {0}")] - TomlEdit(PathBuf, #[source] tools_toml::Error), - #[error("Failed to read `tools.toml` metadata at {0}")] - TomlRead(PathBuf, #[source] Box), + #[error("Failed to update `uv-receipt.toml` at {0}")] + ReceiptWrite(PathBuf, #[source] Box), + #[error("Failed to read `uv-receipt.toml` at {0}")] + ReceiptRead(PathBuf, #[source] Box), #[error(transparent)] VirtualEnvError(#[from] uv_virtualenv::Error), #[error("Failed to read package entry points {0}")] @@ -36,6 +37,8 @@ pub enum Error { NoExecutableDirectory, #[error(transparent)] EnvironmentError(#[from] uv_toolchain::Error), + #[error("Failed to find a receipt for tool `{0}` at {1}")] + MissingToolReceipt(String, PathBuf), } /// A collection of uv-managed tools installed on the current system. @@ -51,7 +54,10 @@ impl InstalledTools { Self { root: root.into() } } + /// Create a new [`InstalledTools`] from settings. + /// /// Prefer, in order: + /// /// 1. The specific tool directory specified by the user, i.e., `UV_TOOL_DIR` /// 2. A directory in the system-appropriate user-level data directory, e.g., `~/.local/uv/tools` /// 3. A directory in the local data directory, e.g., `./.uv/tools` @@ -65,47 +71,74 @@ impl InstalledTools { } } - pub fn tools_toml_path(&self) -> PathBuf { - self.root.join("tools.toml") + /// Return the metadata for all installed tools. + pub fn tools(&self) -> Result, Error> { + let _lock = self.acquire_lock(); + let mut tools = Vec::new(); + for directory in uv_fs::directories(self.root()) { + let name = directory.file_name().unwrap().to_string_lossy().to_string(); + let path = directory.join("uv-receipt.toml"); + let contents = match fs_err::read_to_string(&path) { + Ok(contents) => contents, + // TODO(zanieb): Consider warning on malformed tools instead + Err(err) if err.kind() == io::ErrorKind::NotFound => { + return Err(Error::MissingToolReceipt(name.clone(), path.clone())) + } + Err(err) => return Err(err.into()), + }; + let tool_receipt = ToolReceipt::from_string(contents) + .map_err(|err| Error::ReceiptRead(path, Box::new(err)))?; + tools.push((name, tool_receipt.tool)); + } + Ok(tools) } - /// Return the toml tracking tools. - pub fn toml(&self) -> Result { - match fs_err::read_to_string(self.tools_toml_path()) { - Ok(contents) => Ok(ToolsToml::from_string(contents) - .map_err(|err| Error::TomlRead(self.tools_toml_path(), Box::new(err)))?), - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(ToolsToml::default()), - Err(err) => Err(err.into()), + /// Get the receipt for the given tool. + pub fn get_tool_receipt(&self, name: &str) -> Result, Error> { + let path = self.root.join(name).join("uv-receipt.toml"); + match ToolReceipt::from_path(&path) { + Ok(tool_receipt) => Ok(Some(tool_receipt.tool)), + Err(Error::IO(err)) if err.kind() == io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), } } - pub fn toml_mut(&self) -> Result { - let toml = self.toml()?; - ToolsTomlMut::from_toml(&toml).map_err(|err| Error::TomlEdit(self.tools_toml_path(), err)) - } - - pub fn find_tool_entry(&self, name: &str) -> Result, Error> { - let toml = self.toml()?; - Ok(toml.tools.and_then(|tools| tools.get(name).cloned())) - } - - pub fn acquire_lock(&self) -> Result { + /// Lock the tools directory. + fn acquire_lock(&self) -> Result { Ok(LockedFile::acquire( self.root.join(".lock"), self.root.user_display(), )?) } - pub fn add_tool_entry(&self, name: &str, tool: &Tool) -> Result<(), Error> { - let _lock = self.acquire_lock(); + /// Lock a tool directory. + fn acquire_tool_lock(&self, name: &str) -> Result { + let path = self.root.join(name); + Ok(LockedFile::acquire( + path.join(".lock"), + path.user_display(), + )?) + } - let mut toml_mut = self.toml_mut()?; - toml_mut - .add_tool(name, tool) - .map_err(|err| Error::TomlEdit(self.tools_toml_path(), err))?; + /// Add a receipt for a tool. + /// + /// Any existing receipt will be replaced. + pub fn add_tool_receipt(&self, name: &str, tool: Tool) -> Result<(), Error> { + let _lock = self.acquire_tool_lock(name); + + let tool_receipt = ToolReceipt::from(tool); + let path = self.root.join(name).join("uv-receipt.toml"); + + debug!( + "Adding metadata entry for tool `{name}` at {}", + path.user_display() + ); + + let doc = toml::to_string(&tool_receipt) + .map_err(|err| Error::ReceiptWrite(path.clone(), Box::new(err)))?; // Save the modified `tools.toml`. - fs_err::write(self.tools_toml_path(), toml_mut.to_string())?; + fs_err::write(&path, doc)?; Ok(()) } @@ -189,6 +222,7 @@ impl InstalledTools { Ok(self) } + /// Return the path of the tools directory. pub fn root(&self) -> &Path { &self.root } diff --git a/crates/uv-tool/src/receipt.rs b/crates/uv-tool/src/receipt.rs new file mode 100644 index 000000000..abf130a09 --- /dev/null +++ b/crates/uv-tool/src/receipt.rs @@ -0,0 +1,51 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::Tool; + +/// A `uv-receipt.toml` file tracking the installation of a tool. +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolReceipt { + pub(crate) tool: Tool, + + /// The raw unserialized document. + #[serde(skip)] + pub(crate) raw: String, +} + +impl ToolReceipt { + /// Parse a [`ToolReceipt`] from a raw TOML string. + pub(crate) fn from_string(raw: String) -> Result { + let tool = toml::from_str(&raw)?; + Ok(ToolReceipt { raw, ..tool }) + } + + /// Read a [`ToolReceipt`] from the given path. + pub(crate) fn from_path(path: &Path) -> Result { + match fs_err::read_to_string(path) { + Ok(contents) => Ok(ToolReceipt::from_string(contents) + .map_err(|err| crate::Error::ReceiptRead(path.to_owned(), Box::new(err)))?), + Err(err) => Err(err.into()), + } + } +} + +// Ignore raw document in comparison. +impl PartialEq for ToolReceipt { + fn eq(&self, other: &Self) -> bool { + self.tool.eq(&other.tool) + } +} + +impl Eq for ToolReceipt {} + +impl From for ToolReceipt { + fn from(tool: Tool) -> Self { + ToolReceipt { + tool, + raw: String::new(), + } + } +} diff --git a/crates/uv-tool/src/tool.rs b/crates/uv-tool/src/tool.rs new file mode 100644 index 000000000..f5d4e98db --- /dev/null +++ b/crates/uv-tool/src/tool.rs @@ -0,0 +1,25 @@ +use pypi_types::VerbatimParsedUrl; +use serde::{Deserialize, Serialize}; + +/// A tool entry. +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct Tool { + requirements: Vec>, + python: Option, +} + +impl Tool { + /// Create a new `Tool`. + pub fn new( + requirements: Vec>, + python: Option, + ) -> Self { + Self { + requirements, + python, + } + } +} diff --git a/crates/uv-tool/src/tools_toml.rs b/crates/uv-tool/src/tools_toml.rs deleted file mode 100644 index 75d7ecd9c..000000000 --- a/crates/uv-tool/src/tools_toml.rs +++ /dev/null @@ -1,119 +0,0 @@ -use pypi_types::VerbatimParsedUrl; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::{fmt, mem}; -use thiserror::Error; -use toml_edit::{DocumentMut, Item, Table, TomlError, Value}; - -/// A `tools.toml` with an (optional) `[tools]` section. -#[allow(dead_code)] -#[derive(Debug, Clone, Default, Deserialize)] -pub struct ToolsToml { - pub(crate) tools: Option>, - - /// The raw unserialized document. - #[serde(skip)] - pub(crate) raw: String, -} - -impl ToolsToml { - /// Parse a `ToolsToml` from a raw TOML string. - pub(crate) fn from_string(raw: String) -> Result { - let tools = toml::from_str(&raw)?; - Ok(ToolsToml { raw, ..tools }) - } -} - -// Ignore raw document in comparison. -impl PartialEq for ToolsToml { - fn eq(&self, other: &Self) -> bool { - self.tools.eq(&other.tools) - } -} - -impl Eq for ToolsToml {} - -/// A `[[tools]]` entry. -#[allow(dead_code)] -#[derive(Debug, Clone, Serialize, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "kebab-case")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct Tool { - requirements: Vec>, - python: Option, -} - -impl Tool { - /// Create a new `Tool`. - pub fn new( - requirements: Vec>, - python: Option, - ) -> Self { - Self { - requirements, - python, - } - } -} - -/// Raw and mutable representation of a `tools.toml`. -/// -/// This is useful for operations that require editing an existing `tools.toml` while -/// preserving comments and other structure. -pub struct ToolsTomlMut { - doc: DocumentMut, -} - -#[derive(Error, Debug)] -pub enum Error { - #[error("Failed to parse `tools.toml`")] - Parse(#[from] Box), - #[error("Failed to serialize `tools.toml`")] - Serialize(#[from] Box), - #[error("`tools.toml` is malformed")] - MalformedTools, -} - -impl ToolsTomlMut { - /// Initialize a `ToolsTomlMut` from a `ToolsToml`. - pub fn from_toml(tools: &ToolsToml) -> Result { - Ok(Self { - doc: tools.raw.parse().map_err(Box::new)?, - }) - } - - /// Adds a tool to `tools`. - pub fn add_tool(&mut self, name: &str, tool: &Tool) -> Result<(), Error> { - // Get or create `tools`. - let tools = self - .doc - .entry("tools") - .or_insert(Item::Table(Table::new())) - .as_table_mut() - .ok_or(Error::MalformedTools)?; - - add_tool(name, tool, tools)?; - - Ok(()) - } -} - -/// Adds a tool to the given `tools` table. -pub(crate) fn add_tool(name: &str, tool: &Tool, tools: &mut Table) -> Result<(), Error> { - // Serialize as an inline table. - let mut doc = toml::to_string(tool) - .map_err(Box::new)? - .parse::() - .unwrap(); - let table = mem::take(doc.as_table_mut()).into_inline_table(); - - tools.insert(name, Item::Value(Value::InlineTable(table))); - - Ok(()) -} - -impl fmt::Display for ToolsTomlMut { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.doc.fmt(f) - } -} diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index bf8a80183..8b6354724 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -9,7 +9,7 @@ use itertools::Itertools; use pep508_rs::Requirement; use pypi_types::VerbatimParsedUrl; -use tracing::{debug, trace}; +use tracing::debug; use uv_cache::Cache; use uv_client::Connectivity; use uv_configuration::{Concurrency, PreviewMode, Reinstall}; @@ -53,9 +53,9 @@ pub(crate) async fn install( // 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)?; + let existing_tool_receipt = installed_tools.get_tool_receipt(&name)?; // TODO(zanieb): Automatically replace an existing tool if the request differs - let reinstall_entry_points = if existing_tool_entry.is_some() { + let reinstall_entry_points = if existing_tool_receipt.is_some() { if force { debug!("Replacing existing tool due to `--force` flag."); false @@ -93,7 +93,6 @@ pub(crate) async fn install( bail!("Expected at least one requirement") }; let tool = Tool::new(requirements, python.clone()); - let path = installed_tools.tools_toml_path(); let interpreter = Toolchain::find( &python @@ -138,7 +137,7 @@ pub(crate) async fn install( // 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 { + if existing_tool_receipt.is_some() && !reinstall_entry_points { writeln!(printer.stderr(), "Updated environment for tool `{name}`")?; return Ok(ExitStatus::Success); } @@ -214,18 +213,16 @@ pub(crate) async fn install( #[cfg(windows)] fs_err::copy(path, target).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.stdout(), "Installed: {}", targets.iter().map(|(name, _, _)| name).join(", ") )?; - 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)?; - Ok(ExitStatus::Success) } diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index e06208287..0d4252788 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -44,7 +44,8 @@ fn tool_install() { tool_dir.child("black").assert(predicate::path::is_dir()); tool_dir - .child("tools.toml") + .child("black") + .child("uv-receipt.toml") .assert(predicate::path::exists()); let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX)); @@ -72,10 +73,10 @@ fn tool_install() { insta::with_settings!({ filters => context.filters(), }, { - // We should have a tool entry - assert_snapshot!(fs_err::read_to_string(tool_dir.join("tools.toml")).unwrap(), @r###" - [tools] - black = { requirements = ["black"] } + // 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 = ["black"] "###); }); @@ -149,11 +150,10 @@ fn tool_install() { insta::with_settings!({ filters => context.filters(), }, { - // We should have an additional tool entry - assert_snapshot!(fs_err::read_to_string(tool_dir.join("tools.toml")).unwrap(), @r###" - [tools] - black = { requirements = ["black"] } - flask = { requirements = ["flask"] } + // We should have a new tool receipt + assert_snapshot!(fs_err::read_to_string(tool_dir.join("flask").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = ["flask"] "###); }); } @@ -197,7 +197,8 @@ fn tool_install_already_installed() { tool_dir.child("black").assert(predicate::path::is_dir()); tool_dir - .child("tools.toml") + .child("black") + .child("uv-receipt.toml") .assert(predicate::path::exists()); let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX)); @@ -225,10 +226,10 @@ fn tool_install_already_installed() { insta::with_settings!({ filters => context.filters(), }, { - // We should have a tool entry - assert_snapshot!(fs_err::read_to_string(tool_dir.join("tools.toml")).unwrap(), @r###" - [tools] - black = { requirements = ["black"] } + // 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 = ["black"] "###); }); @@ -254,10 +255,10 @@ fn tool_install_already_installed() { insta::with_settings!({ filters => context.filters(), }, { - // We should not have an additional tool entry - assert_snapshot!(fs_err::read_to_string(tool_dir.join("tools.toml")).unwrap(), @r###" - [tools] - black = { requirements = ["black"] } + // We should not have an additional tool receipt + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = ["black"] "###); }); @@ -378,7 +379,7 @@ fn tool_install_entry_point_exists() { assert!(!tool_dir.child("black").exists()); // We should not write a tools entry - assert!(!tool_dir.join("tools.toml").exists()); + assert!(!tool_dir.join("black").join("uv-receipt.toml").exists()); insta::with_settings!({ filters => context.filters(), @@ -480,10 +481,10 @@ fn tool_install_entry_point_exists() { insta::with_settings!({ filters => context.filters(), }, { - // We write a tool entry - assert_snapshot!(fs_err::read_to_string(tool_dir.join("tools.toml")).unwrap(), @r###" - [tools] - black = { requirements = ["black"] } + // We write a tool receipt + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = ["black"] "###); }); @@ -509,10 +510,10 @@ fn tool_install_entry_point_exists() { insta::with_settings!({ filters => context.filters(), }, { - // We should have a tool entry - assert_snapshot!(fs_err::read_to_string(tool_dir.join("tools.toml")).unwrap(), @r###" - [tools] - black = { requirements = ["black"] } + // 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 = ["black"] "###); });