Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Track tool entry points in receipts #4634

Merged
merged 3 commits into from
Jun 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading