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

[WIP] Support installing additional executables in uv tool install #7592

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3338,6 +3338,10 @@ pub struct ToolInstallArgs {
#[arg(long)]
pub with: Vec<String>,

/// Additionally include entrypoints from the following packages.
#[arg(long)]
pub i_want_ponies: Vec<PackageName>,

/// Run all requirements listed in the given `requirements.txt` files.
#[arg(long, value_parser = parse_maybe_file_path)]
pub with_requirements: Vec<Maybe<PathBuf>>,
Expand Down
6 changes: 6 additions & 0 deletions crates/uv-requirements/src/sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use console::Term;

use uv_fs::Simplified;
use uv_normalize::PackageName;
use uv_warnings::warn_user;

use crate::confirm;
Expand Down Expand Up @@ -123,6 +124,11 @@ impl RequirementsSource {
Self::Package(name)
}

/// Build a [`RequirementsSource`] from a [`PackageName`].
pub fn from_package_name(pkg_name: &PackageName) -> Self {
Self::Package(pkg_name.to_string())
}

/// Parse a [`RequirementsSource`] from a user-provided string, assumed to be a path to a source
/// tree.
pub fn from_source_tree(path: PathBuf) -> Self {
Expand Down
17 changes: 13 additions & 4 deletions crates/uv-tool/src/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ impl TryFrom<ToolWire> for Tool {
pub struct ToolEntrypoint {
pub name: String,
pub install_path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
}

/// Format an array so that each element is on its own line and has a trailing comma.
Expand Down Expand Up @@ -118,10 +120,10 @@ impl Tool {
pub fn new(
requirements: Vec<Requirement>,
python: Option<String>,
entrypoints: impl Iterator<Item = ToolEntrypoint>,
entrypoints: Vec<ToolEntrypoint>,
options: ToolOptions,
) -> Self {
let mut entrypoints: Vec<_> = entrypoints.collect();
let mut entrypoints = entrypoints;
entrypoints.sort();
Self {
requirements,
Expand Down Expand Up @@ -208,8 +210,12 @@ impl Tool {

impl ToolEntrypoint {
/// Create a new [`ToolEntrypoint`].
pub fn new(name: String, install_path: PathBuf) -> Self {
Self { name, install_path }
pub fn new(name: String, install_path: PathBuf, from: String) -> Self {
Self {
name,
install_path,
from: Some(from),
}
}

/// Returns the TOML table for this entrypoint.
Expand All @@ -221,6 +227,9 @@ impl ToolEntrypoint {
// Use cross-platform slashes so the toml string type does not change
value(PortablePath::from(&self.install_path).to_string()),
);
if let Some(from) = &self.from {
table.insert("from", value(from));
};
table
}
}
72 changes: 39 additions & 33 deletions crates/uv/src/commands/tool/common.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use std::env::consts::EXE_SUFFIX;
use std::fmt::Write;
use std::path::Path;
use std::{collections::BTreeSet, ffi::OsString};

use anyhow::{bail, Context};
Expand All @@ -16,10 +18,9 @@ use uv_installer::SitePackages;
use uv_python::PythonEnvironment;
use uv_settings::ToolOptions;
use uv_shell::Shell;
use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint};
use uv_tool::{entrypoint_paths, InstalledTools, Tool, ToolEntrypoint};
use uv_warnings::warn_user;

use crate::commands::ExitStatus;
use crate::printer::Printer;

/// Return all packages which contain an executable with the given name.
Expand Down Expand Up @@ -64,25 +65,23 @@ pub(crate) fn remove_entrypoints(tool: &Tool) {
/// Installs tool executables for a given package and handles any conflicts.
pub(crate) fn install_executables(
environment: &PythonEnvironment,
tool_name: &PackageName,
name: &PackageName,
installed_tools: &InstalledTools,
existing_entrypoints: &[ToolEntrypoint],
executable_directory: &Path,
options: ToolOptions,
force: bool,
python: Option<String>,
requirements: Vec<Requirement>,
printer: Printer,
) -> anyhow::Result<ExitStatus> {
) -> anyhow::Result<Vec<ToolEntrypoint>> {
let site_packages = SitePackages::from_environment(environment)?;
let installed = site_packages.get_packages(name);
let Some(installed_dist) = installed.first().copied() else {
bail!("Expected at least one requirement")
};

// Find a suitable path to install into
let executable_directory = find_executable_directory()?;
fs_err::create_dir_all(&executable_directory)
.context("Failed to create executable directory")?;

debug!(
"Installing tool executables into: {}",
executable_directory.user_display()
Expand Down Expand Up @@ -120,7 +119,10 @@ pub(crate) fn install_executables(
// Clean up the environment we just created.
installed_tools.remove_environment(name)?;

return Ok(ExitStatus::Failure);
return Err(anyhow::anyhow!(
"Failed to install entrypoints for `{from}`",
from = name.cyan()
));
}

// Check if they exist, before installing
Expand All @@ -129,7 +131,7 @@ pub(crate) fn install_executables(
.filter(|(_, _, target_path)| target_path.exists())
.peekable();

// Ignore any existing entrypoints if the user passed `--force`, or the existing recept was
// Ignore any existing entrypoints if the user passed `--force`, or the existing receipt was
// broken.
if force {
for (name, _, target) in existing_entry_points {
Expand All @@ -138,7 +140,7 @@ pub(crate) fn install_executables(
}
} else if existing_entry_points.peek().is_some() {
// Clean up the environment we just created
installed_tools.remove_environment(name)?;
installed_tools.remove_environment(tool_name)?;

let existing_entry_points = existing_entry_points
// SAFETY: We know the target has a filename because we just constructed it above
Expand All @@ -158,44 +160,49 @@ pub(crate) fn install_executables(
)
}

let mut names = BTreeSet::new();
for (name, source_path, target_path) in &target_entry_points {
debug!("Installing executable: `{name}`");
#[cfg(unix)]
replace_symlink(source_path, target_path).context("Failed to install executable")?;
#[cfg(windows)]
fs_err::copy(source_path, target_path).context("Failed to install entrypoint")?;
names.insert(name.trim_end_matches(EXE_SUFFIX));
}

let s = if target_entry_points.len() == 1 {
""
let s = if names.len() == 1 { "" } else { "s" };
let from_pkg = if tool_name == name {
String::new()
} else {
"s"
format!(" from `{name}`")
};
writeln!(
printer.stderr(),
"Installed {} executable{s}: {}",
target_entry_points.len(),
target_entry_points
.iter()
.map(|(name, _, _)| name.bold())
.join(", ")
"Installed {} executable{s}{from_pkg}: {}",
names.len(),
names.iter().map(|name| name.bold()).join(", ")
)?;

debug!("Adding receipt for tool `{name}`");
let tool = Tool::new(
requirements.into_iter().collect(),
python,
target_entry_points
.into_iter()
.map(|(name, _, target_path)| ToolEntrypoint::new(name, target_path)),
options,
);
installed_tools.add_tool_receipt(name, tool)?;
debug!("Adding receipt for tool `{tool_name}`");

let mut all_entrypoints = existing_entrypoints.to_vec();
let mut new_entrypoints = Vec::with_capacity(target_entry_points.len());
for (entry, _, target_path) in target_entry_points {
let tool_entry = ToolEntrypoint::new(entry, target_path, name.to_string());
all_entrypoints.push(tool_entry.clone());
new_entrypoints.push(tool_entry);
}
let tool = Tool::new(requirements, python, all_entrypoints, options);
installed_tools.add_tool_receipt(tool_name, tool.clone())?;

Ok(new_entrypoints)
}

pub(crate) fn warn_out_of_path(executable_directory: &Path) {
// If the executable directory isn't on the user's PATH, warn.
if !Shell::contains_path(&executable_directory) {
if !Shell::contains_path(executable_directory) {
if let Some(shell) = Shell::from_env() {
if let Some(command) = shell.prepend_path(&executable_directory) {
if let Some(command) = shell.prepend_path(executable_directory) {
if shell.configuration_files().is_empty() {
warn_user!(
"`{}` is not on your PATH. To use installed tools, run `{}`.",
Expand Down Expand Up @@ -223,7 +230,6 @@ pub(crate) fn install_executables(
);
}
}
Ok(ExitStatus::Success)
}

/// Displays a hint if an executable matching the package name can be found in a dependency of the package.
Expand Down
54 changes: 49 additions & 5 deletions crates/uv/src/commands/tool/install.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
use std::collections::BTreeSet;
use std::fmt::Write;
use std::str::FromStr;

use anyhow::{bail, Result};
use anyhow::{bail, Context as _, Result};
use distribution_types::UnresolvedRequirementSpecification;
use owo_colors::OwoColorize;
use pep440_rs::{VersionSpecifier, VersionSpecifiers};
use pep508_rs::MarkerTree;
use pypi_types::{Requirement, RequirementSource};
use tracing::trace;
use tracing::{debug, trace};
use uv_cache::{Cache, Refresh};
use uv_cache_info::Timestamp;
use uv_client::{BaseClientBuilder, Connectivity};
Expand All @@ -27,7 +28,7 @@ use crate::commands::project::{
resolve_environment, resolve_names, sync_environment, update_environment,
EnvironmentSpecification,
};
use crate::commands::tool::common::remove_entrypoints;
use crate::commands::tool::common::{remove_entrypoints, warn_out_of_path};
use crate::commands::tool::Target;
use crate::commands::{reporters::PythonDownloadReporter, tool::common::install_executables};
use crate::commands::{ExitStatus, SharedState};
Expand All @@ -40,6 +41,7 @@ pub(crate) async fn install(
editable: bool,
from: Option<String>,
with: &[RequirementsSource],
extra_entrypoints_packages: BTreeSet<PackageName>,
python: Option<String>,
force: bool,
options: ResolverInstallerOptions,
Expand Down Expand Up @@ -406,14 +408,56 @@ pub(crate) async fn install(
.await?
};

// TODO(lucab): we support performing multiple installs in the same environment.
// This force-installs, maybe it can be modeled in a more graceful way instead?
let force_install = force || invalid_tool_receipt || !extra_entrypoints_packages.is_empty();

// Find a suitable path to install into.
let executable_directory = uv_tool::find_executable_directory()?;
fs_err::create_dir_all(&executable_directory)
.context("Failed to create executable directory")?;

// Install additional entrypoints from dependencies,
// if any was explicitly requested.
let mut deps_entrypoints = vec![];
for pkg in extra_entrypoints_packages {
debug!(
"Installing entrypoints for {} as part of tool {}",
pkg, from.name
);
let mut entrypoints = install_executables(
&environment,
&from.name,
&pkg,
&installed_tools,
&deps_entrypoints,
&executable_directory,
options.clone(),
force_install,
python.clone(),
requirements.clone(),
printer,
)?;
deps_entrypoints.append(&mut entrypoints);
}

// Install entrypoints from the target package.
debug!("Installing entrypoints tool {}", from.name);
install_executables(
&environment,
&from.name,
&from.name,
&installed_tools,
&deps_entrypoints,
&executable_directory,
options,
force || invalid_tool_receipt,
force_install,
python,
requirements,
printer,
)
)?;

warn_out_of_path(&executable_directory);

Ok(ExitStatus::Success)
}
7 changes: 6 additions & 1 deletion crates/uv/src/commands/tool/uninstall.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::env::consts::EXE_SUFFIX;
use std::fmt::Write;

use anyhow::{bail, Result};
Expand Down Expand Up @@ -147,7 +148,11 @@ async fn do_uninstall(
}
entrypoints
};
entrypoints.sort_unstable_by(|a, b| a.name.cmp(&b.name));
entrypoints.sort_unstable_by(|a, b| {
let a_trimmed = a.name.trim_end_matches(EXE_SUFFIX);
let b_trimmed = b.name.trim_end_matches(EXE_SUFFIX);
a_trimmed.cmp(b_trimmed)
});

if entrypoints.is_empty() {
// If we removed at least one dangling environment, there's no need to summarize.
Expand Down
14 changes: 13 additions & 1 deletion crates/uv/src/commands/tool/upgrade.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::{collections::BTreeSet, fmt::Write};

use anyhow::Result;
use anyhow::{Context as _, Result};
use owo_colors::OwoColorize;
use tracing::debug;

Expand Down Expand Up @@ -28,6 +28,8 @@ use crate::commands::{tool::common::install_executables, ExitStatus, SharedState
use crate::printer::Printer;
use crate::settings::ResolverInstallerSettings;

use super::common::warn_out_of_path;

/// Upgrade a tool.
pub(crate) async fn upgrade(
name: Vec<PackageName>,
Expand Down Expand Up @@ -323,17 +325,27 @@ async fn upgrade_tool(
// existing executables.
remove_entrypoints(&existing_tool_receipt);

// Find a suitable path to install into.
let executable_directory = uv_tool::find_executable_directory()?;
fs_err::create_dir_all(&executable_directory)
.context("Failed to create executable directory")?;

// If we modified the target tool, reinstall the entrypoints.
install_executables(
&environment,
name,
name,
installed_tools,
&[],
&executable_directory,
ToolOptions::from(options),
true,
existing_tool_receipt.python().to_owned(),
requirements.to_vec(),
printer,
)?;

warn_out_of_path(&executable_directory);
}

Ok(outcome)
Expand Down
Loading
Loading