Move from a shared tools.toml to separated tool receipts (#4560)

Refactors the installed tool metadata per commentary in #4492 

We now store a `uv-receipt.toml` per tool install instead of a single
`tools.toml`
This commit is contained in:
Zanie Blue 2024-06-26 16:48:18 -04:00 committed by GitHub
parent 909b69dfa2
commit fc681ec738
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 182 additions and 195 deletions

1
Cargo.lock generated
View file

@ -5025,7 +5025,6 @@ dependencies = [
"serde",
"thiserror",
"toml",
"toml_edit",
"tracing",
"uv-cache",
"uv-fs",

View file

@ -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 }

View file

@ -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<toml::de::Error>),
#[error("Failed to update `uv-receipt.toml` at {0}")]
ReceiptWrite(PathBuf, #[source] Box<toml::ser::Error>),
#[error("Failed to read `uv-receipt.toml` at {0}")]
ReceiptRead(PathBuf, #[source] Box<toml::de::Error>),
#[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<Vec<(String, Tool)>, 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<ToolsToml, Error> {
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<Option<Tool>, 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<ToolsTomlMut, Error> {
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<Option<Tool>, Error> {
let toml = self.toml()?;
Ok(toml.tools.and_then(|tools| tools.get(name).cloned()))
}
pub fn acquire_lock(&self) -> Result<LockedFile, Error> {
/// Lock the tools directory.
fn acquire_lock(&self) -> Result<LockedFile, Error> {
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<LockedFile, Error> {
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
}

View file

@ -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<Self, toml::de::Error> {
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<ToolReceipt, crate::Error> {
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<Tool> for ToolReceipt {
fn from(tool: Tool) -> Self {
ToolReceipt {
tool,
raw: String::new(),
}
}
}

View file

@ -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<pep508_rs::Requirement<VerbatimParsedUrl>>,
python: Option<String>,
}
impl Tool {
/// Create a new `Tool`.
pub fn new(
requirements: Vec<pep508_rs::Requirement<VerbatimParsedUrl>>,
python: Option<String>,
) -> Self {
Self {
requirements,
python,
}
}
}

View file

@ -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<BTreeMap<String, Tool>>,
/// 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<Self, toml::de::Error> {
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<pep508_rs::Requirement<VerbatimParsedUrl>>,
python: Option<String>,
}
impl Tool {
/// Create a new `Tool`.
pub fn new(
requirements: Vec<pep508_rs::Requirement<VerbatimParsedUrl>>,
python: Option<String>,
) -> 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<TomlError>),
#[error("Failed to serialize `tools.toml`")]
Serialize(#[from] Box<toml::ser::Error>),
#[error("`tools.toml` is malformed")]
MalformedTools,
}
impl ToolsTomlMut {
/// Initialize a `ToolsTomlMut` from a `ToolsToml`.
pub fn from_toml(tools: &ToolsToml) -> Result<Self, Error> {
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::<DocumentMut>()
.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)
}
}

View file

@ -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::<VerbatimParsedUrl>::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)
}

View file

@ -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"]
"###);
});