mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 13:25:00 +00:00
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:
parent
72438ef5bb
commit
3a627f3799
7 changed files with 206 additions and 50 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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(())
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue