Skip to content

Commit

Permalink
Track tool entry points in receipts (#4634)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
zanieb authored Jun 29, 2024
1 parent 72438ef commit 3a627f3
Show file tree
Hide file tree
Showing 7 changed files with 203 additions and 47 deletions.
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 11 additions & 9 deletions crates/uv-tool/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,22 @@ license = { workspace = true }
workspace = true

[dependencies]
uv-fs = { workspace = true }
uv-state = { workspace = true }
install-wheel-rs = { workspace = true }
pep440_rs = { workspace = true }
pep508_rs = { workspace = true }
pypi-types = { workspace = true }
uv-virtualenv = { workspace = true }
uv-cache = { workspace = true }
uv-fs = { workspace = true }
uv-state = { workspace = true }
uv-toolchain = { workspace = true }
install-wheel-rs = { workspace = true }
pep440_rs = { workspace = true }
uv-virtualenv = { workspace = true }
uv-warnings = { workspace = true }
uv-cache = { workspace = true }

thiserror = { workspace = true }
tracing = { workspace = true }
dirs-sys = { workspace = true }
fs-err = { workspace = true }
path-slash = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
dirs-sys = { workspace = true }
toml_edit = { workspace = true }
tracing = { workspace = true }
7 changes: 3 additions & 4 deletions crates/uv-tool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(())
Expand Down
14 changes: 12 additions & 2 deletions crates/uv-tool/src/receipt.rs
Original file line number Diff line number Diff line change
@@ -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,

Expand All @@ -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.
Expand Down
106 changes: 104 additions & 2 deletions crates/uv-tool/src/tool.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,127 @@
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 {
/// Create a new `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
}
}
56 changes: 33 additions & 23 deletions crates/uv/src/commands/tool/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -176,76 +175,87 @@ 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(),
)?;

// 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)
}
Loading

0 comments on commit 3a627f3

Please sign in to comment.