Track tool entry points in receipts (#4634)

We need this to power uninstallations! 

The latter two commits were reviewed in:

- #4637 
- #4638 

Note this is a breaking change for existing tool installations, but it's
in preview and very new. In the future, we'll need a clear upgrade path
for tool receipt changes.
This commit is contained in:
Zanie Blue 2024-06-28 23:45:40 -04:00 committed by GitHub
parent 72438ef5bb
commit 3a627f3799
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 206 additions and 50 deletions

2
Cargo.lock generated
View file

@ -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",

View file

@ -13,20 +13,22 @@ license = { workspace = true }
workspace = true
[dependencies]
uv-fs = { workspace = true }
uv-state = { workspace = true }
pep508_rs = { workspace = true }
pypi-types = { workspace = true }
uv-virtualenv = { workspace = true }
uv-toolchain = { workspace = true }
install-wheel-rs = { workspace = true }
pep440_rs = { workspace = true }
uv-warnings = { workspace = true }
pep508_rs = { workspace = true }
pypi-types = { workspace = true }
uv-cache = { workspace = true }
uv-fs = { workspace = true }
uv-state = { workspace = true }
uv-toolchain = { workspace = true }
uv-virtualenv = { workspace = true }
uv-warnings = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
fs-err = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
dirs-sys = { workspace = true }
fs-err = { workspace = true }
path-slash = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
toml_edit = { workspace = true }
tracing = { workspace = true }

View file

@ -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(())

View file

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

View file

@ -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<pep508_rs::Requirement<VerbatimParsedUrl>>,
/// The Python requested by the user during installation.
python: Option<String>,
// A mapping of entry point names to their metadata.
entrypoints: Vec<ToolEntrypoint>,
}
#[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<Item = impl Into<Value>>) -> 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::<Array>();
// 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<pep508_rs::Requirement<VerbatimParsedUrl>>,
python: Option<String>,
entrypoints: impl Iterator<Item = ToolEntrypoint>,
) -> 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
}
}

View file

@ -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::<BTreeSet<_>>();
// 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::<Vec<_>>();
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)
}

View file

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