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", "dirs-sys",
"fs-err", "fs-err",
"install-wheel-rs", "install-wheel-rs",
"path-slash",
"pep440_rs", "pep440_rs",
"pep508_rs", "pep508_rs",
"pypi-types", "pypi-types",
"serde", "serde",
"thiserror", "thiserror",
"toml", "toml",
"toml_edit",
"tracing", "tracing",
"uv-cache", "uv-cache",
"uv-fs", "uv-fs",

View file

@ -13,20 +13,22 @@ license = { workspace = true }
workspace = true workspace = true
[dependencies] [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 } install-wheel-rs = { workspace = true }
pep440_rs = { workspace = true } pep440_rs = { workspace = true }
uv-warnings = { workspace = true } pep508_rs = { workspace = true }
pypi-types = { workspace = true }
uv-cache = { 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 } 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; use uv_warnings::warn_user_once;
pub use receipt::ToolReceipt; pub use receipt::ToolReceipt;
pub use tool::Tool; pub use tool::{Tool, ToolEntrypoint};
use uv_state::{StateBucket, StateStore}; use uv_state::{StateBucket, StateStore};
mod receipt; mod receipt;
@ -135,10 +135,9 @@ impl InstalledTools {
path.user_display() path.user_display()
); );
let doc = toml::to_string(&tool_receipt) let doc = tool_receipt.to_toml();
.map_err(|err| Error::ReceiptWrite(path.clone(), Box::new(err)))?;
// Save the modified `tools.toml`. // Save the modified `uv-receipt.toml`.
fs_err::write(&path, doc)?; fs_err::write(&path, doc)?;
Ok(()) Ok(())

View file

@ -1,12 +1,12 @@
use std::path::Path; use std::path::Path;
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use crate::Tool; use crate::Tool;
/// A `uv-receipt.toml` file tracking the installation of a tool. /// A `uv-receipt.toml` file tracking the installation of a tool.
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct ToolReceipt { pub struct ToolReceipt {
pub(crate) tool: Tool, pub(crate) tool: Tool,
@ -30,6 +30,16 @@ impl ToolReceipt {
Err(err) => Err(err.into()), 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. // Ignore raw document in comparison.

View file

@ -1,14 +1,59 @@
use std::path::PathBuf;
use path_slash::PathBufExt;
use pypi_types::VerbatimParsedUrl; 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. /// A tool entry.
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Tool { pub struct Tool {
// The requirements requested by the user during installation.
requirements: Vec<pep508_rs::Requirement<VerbatimParsedUrl>>, requirements: Vec<pep508_rs::Requirement<VerbatimParsedUrl>>,
/// The Python requested by the user during installation.
python: Option<String>, 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 { impl Tool {
@ -16,10 +61,67 @@ impl Tool {
pub fn new( pub fn new(
requirements: Vec<pep508_rs::Requirement<VerbatimParsedUrl>>, requirements: Vec<pep508_rs::Requirement<VerbatimParsedUrl>>,
python: Option<String>, python: Option<String>,
entrypoints: impl Iterator<Item = ToolEntrypoint>,
) -> Self { ) -> Self {
let mut entrypoints: Vec<_> = entrypoints.collect();
entrypoints.sort();
Self { Self {
requirements, requirements,
python, 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_fs::Simplified;
use uv_installer::SitePackages; use uv_installer::SitePackages;
use uv_requirements::RequirementsSource; 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_toolchain::{EnvironmentPreference, Toolchain, ToolchainPreference, ToolchainRequest};
use uv_warnings::warn_user_once; use uv_warnings::warn_user_once;
@ -115,7 +115,6 @@ pub(crate) async fn install(
let Some(from) = requirements.first().cloned() else { let Some(from) = requirements.first().cloned() else {
bail!("Expected at least one requirement") bail!("Expected at least one requirement")
}; };
let tool = Tool::new(requirements, python.clone());
let interpreter = Toolchain::find( let interpreter = Toolchain::find(
&python &python
@ -176,7 +175,7 @@ pub(crate) async fn install(
executable_directory.user_display() executable_directory.user_display()
); );
let entrypoints = entrypoint_paths( let entry_points = entrypoint_paths(
&environment, &environment,
installed_dist.name(), installed_dist.name(),
installed_dist.version(), installed_dist.version(),
@ -184,68 +183,79 @@ pub(crate) async fn install(
// Determine the entry points targets // Determine the entry points targets
// Use a sorted collection for deterministic output // Use a sorted collection for deterministic output
let targets = entrypoints let target_entry_points = entry_points
.into_iter() .into_iter()
.map(|(name, path)| { .map(|(name, source_path)| {
let target = executable_directory.join( let target_path = executable_directory.join(
path.file_name() source_path
.file_name()
.map(std::borrow::ToOwned::to_owned) .map(std::borrow::ToOwned::to_owned)
.unwrap_or_else(|| OsString::from(name.clone())), .unwrap_or_else(|| OsString::from(name.clone())),
); );
(name, path, target) (name, source_path, target_path)
}) })
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
// Check if they exist, before installing // Check if they exist, before installing
let mut existing_targets = targets let mut existing_entry_points = target_entry_points
.iter() .iter()
.filter(|(_, _, target)| target.exists()) .filter(|(_, _, target_path)| target_path.exists())
.peekable(); .peekable();
// Note we use `reinstall_entry_points` here instead of `reinstall`; requesting reinstall // 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. // will _not_ remove existing entry points when they are not managed by uv.
if force || reinstall_entry_points { if force || reinstall_entry_points {
for (name, _, target) in existing_targets { for (name, _, target) in existing_entry_points {
debug!("Removing existing entry point `{name}`"); debug!("Removing existing entry point `{name}`");
fs_err::remove_file(target)?; 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 // Clean up the environment we just created
installed_tools.remove_environment(&name)?; 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 // SAFETY: We know the target has a filename because we just constructed it above
.map(|(_, _, target)| target.file_name().unwrap().to_string_lossy()) .map(|(_, _, target)| target.file_name().unwrap().to_string_lossy())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let (s, exists) = if existing_targets.len() == 1 { let (s, exists) = if existing_entry_points.len() == 1 {
("", "exists") ("", "exists")
} else { } else {
("s", "exist") ("s", "exist")
}; };
bail!( bail!(
"Entry point{s} for tool already {exists}: {} (use `--force` to overwrite)", "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 // 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}`"); debug!("Installing `{name}`");
#[cfg(unix)] #[cfg(unix)]
replace_symlink(path, target).context("Failed to install entrypoint")?; replace_symlink(source_path, target_path).context("Failed to install entrypoint")?;
#[cfg(windows)] #[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!( writeln!(
printer.stderr(), printer.stderr(),
"Installed: {}", "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) Ok(ExitStatus::Success)
} }

View file

@ -15,7 +15,9 @@ mod common;
/// Test installing a tool with `uv tool install` /// Test installing a tool with `uv tool install`
#[test] #[test]
fn tool_install() { 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 tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin"); 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###" assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###"
[tool] [tool]
requirements = ["black"] 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###" assert_snapshot!(fs_err::read_to_string(tool_dir.join("flask").join("uv-receipt.toml")).unwrap(), @r###"
[tool] [tool]
requirements = ["flask"] 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 installing a tool at a version
#[test] #[test]
fn tool_install_version() { 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 tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin"); 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###" assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###"
[tool] [tool]
requirements = ["black==24.2.0"] 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 installing a tool with `uv tool install --from`
#[test] #[test]
fn tool_install_from() { 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 tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin"); 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 installing and reinstalling an already installed tool
#[test] #[test]
fn tool_install_already_installed() { 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 tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin"); 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###" assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###"
[tool] [tool]
requirements = ["black"] 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###" assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###"
[tool] [tool]
requirements = ["black"] 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###" assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###"
[tool] [tool]
requirements = ["black"] 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###" assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###"
[tool] [tool]
requirements = ["black"] 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)] #[cfg(unix)]
#[test] #[test]
fn tool_install_home() { 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"); let tool_dir = context.temp_dir.child("tools");
// Install `black` // 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 `uv tool install` when the bin directory is inferred from `$XDG_DATA_HOME`
#[test] #[test]
fn tool_install_xdg_data_home() { 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 tool_dir = context.temp_dir.child("tools");
let data_home = context.temp_dir.child("data/home"); 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 `uv tool install` when the bin directory is set by `$XDG_BIN_HOME`
#[test] #[test]
fn tool_install_xdg_bin_home() { 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 tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin"); let bin_dir = context.temp_dir.child("bin");