Support installing additional executables in uv tool install

This commit is contained in:
Aaron Ang 2025-06-27 12:53:48 -07:00
parent 6a5d2f1ec4
commit 9367ca45ea
9 changed files with 236 additions and 159 deletions

View file

@ -4335,6 +4335,12 @@ pub struct ToolInstallArgs {
#[arg(long)]
pub with_editable: Vec<comma::CommaSeparatedRequirements>,
/// Install executables from an additional package.
///
/// May be provided multiple times.
#[arg(long)]
pub with_executables_from: Vec<PackageName>,
/// Constrain versions using the given requirements files.
///
/// Constraints files are `requirements.txt`-like files that only control the _version_ of a

View file

@ -5,6 +5,7 @@ use anyhow::{Context, Result};
use console::Term;
use uv_fs::{CWD, Simplified};
use uv_pep508::PackageName;
use uv_requirements_txt::RequirementsTxtRequirement;
#[derive(Debug, Clone)]
@ -203,6 +204,16 @@ impl RequirementsSource {
Ok(Self::Package(requirement))
}
/// Parse a [`RequirementsSource`] from a [`PackageName`].
///
/// Unlike [`RequirementsSource::from_package`], this method does not prompt the user and
/// expects a valid [`PackageName`] instead of an arbitrary string.
pub fn from_package_name(name: &PackageName) -> Result<Self> {
let requirement = RequirementsTxtRequirement::parse(name.as_str(), &*CWD, false)
.with_context(|| format!("Failed to parse: `{name}`"))?;
Ok(Self::Package(requirement))
}
/// Parse a [`RequirementsSource`] from a user-provided string, assumed to be a `--with`
/// package (e.g., `uvx --with flask ruff`).
///

View file

@ -103,6 +103,7 @@ impl TryFrom<ToolWire> for Tool {
pub struct ToolEntrypoint {
pub name: String,
pub install_path: PathBuf,
pub from: Option<String>,
}
impl Display for ToolEntrypoint {
@ -166,10 +167,9 @@ impl Tool {
overrides: Vec<Requirement>,
build_constraints: Vec<Requirement>,
python: Option<PythonRequest>,
entrypoints: impl Iterator<Item = ToolEntrypoint>,
mut entrypoints: Vec<ToolEntrypoint>,
options: ToolOptions,
) -> Self {
let mut entrypoints: Vec<_> = entrypoints.collect();
entrypoints.sort();
Self {
requirements,
@ -345,8 +345,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.
@ -358,6 +362,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
}
}

View file

@ -1,9 +1,14 @@
use anyhow::{Context, bail};
use itertools::Itertools;
use owo_colors::OwoColorize;
use std::collections::Bound;
use std::fmt::Write;
use std::{collections::BTreeSet, ffi::OsString};
use std::{
collections::{BTreeSet, Bound},
env::consts::EXE_SUFFIX,
ffi::OsString,
fmt::Write,
iter,
path::Path,
};
use tracing::{debug, warn};
use uv_cache::Cache;
use uv_client::BaseClientBuilder;
@ -22,12 +27,12 @@ use uv_python::{
};
use uv_settings::{PythonInstallMirrors, ToolOptions};
use uv_shell::Shell;
use uv_tool::{InstalledTools, Tool, ToolEntrypoint, entrypoint_paths, tool_executable_dir};
use uv_warnings::warn_user;
use uv_tool::{InstalledTools, Tool, ToolEntrypoint, entrypoint_paths};
use uv_warnings::warn_user_once;
use crate::commands::pip;
use crate::commands::project::ProjectError;
use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::{ExitStatus, pip};
use crate::printer::Printer;
/// Return all packages which contain an executable with the given name.
@ -169,8 +174,9 @@ pub(crate) async fn refine_interpreter(
pub(crate) fn finalize_tool_install(
environment: &PythonEnvironment,
name: &PackageName,
entrypoints: impl IntoIterator<Item = PackageName>,
installed_tools: &InstalledTools,
options: ToolOptions,
options: &ToolOptions,
force: bool,
python: Option<PythonRequest>,
requirements: Vec<Requirement>,
@ -178,120 +184,137 @@ pub(crate) fn finalize_tool_install(
overrides: Vec<Requirement>,
build_constraints: Vec<Requirement>,
printer: Printer,
) -> anyhow::Result<ExitStatus> {
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 = tool_executable_dir()?;
) -> anyhow::Result<()> {
let executable_directory = uv_tool::tool_executable_dir()?;
fs_err::create_dir_all(&executable_directory)
.context("Failed to create executable directory")?;
debug!(
"Installing tool executables into: {}",
executable_directory.user_display()
);
let entry_points = entrypoint_paths(
&site_packages,
installed_dist.name(),
installed_dist.version(),
)?;
// Determine the entry points targets. Use a sorted collection for deterministic output.
let target_entry_points = entry_points
let mut installed_entrypoints = Vec::new();
let site_packages = SitePackages::from_environment(environment)?;
let ordered_packages = entrypoints
// Install dependencies first
.into_iter()
.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, source_path, target_path)
})
.collect::<BTreeSet<_>>();
.filter(|pkg| pkg != name)
.collect::<BTreeSet<_>>()
// Then install the main package last
.into_iter()
.chain(iter::once(name.clone()));
if target_entry_points.is_empty() {
writeln!(
printer.stdout(),
"No executables are provided by `{from}`",
from = name.cyan()
)?;
hint_executable_from_dependency(name, &site_packages, printer)?;
// Clean up the environment we just created.
installed_tools.remove_environment(name)?;
return Ok(ExitStatus::Failure);
}
// Error if we're overwriting an existing entrypoint, unless the user passed `--force`.
if !force {
let mut existing_entry_points = target_entry_points
.iter()
.filter(|(_, _, target_path)| target_path.exists())
.peekable();
if existing_entry_points.peek().is_some() {
// Clean up the environment we just created
installed_tools.remove_environment(name)?;
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_entry_points.len() == 1 {
("", "exists")
} else {
("s", "exist")
};
bail!(
"Executable{s} already {exists}: {} (use `--force` to overwrite)",
existing_entry_points
.iter()
.map(|name| name.bold())
.join(", ")
)
for package in ordered_packages {
if package == *name {
debug!("Installing entrypoints for tool `{package}`");
} else {
debug!("Installing entrypoints for `{package}` as part of tool `{name}`");
}
}
#[cfg(windows)]
let itself = std::env::current_exe().ok();
let installed = site_packages.get_packages(&package);
let dist = installed
.first()
.context("Expected at least one requirement")?;
let dist_entrypoints = entrypoint_paths(&site_packages, dist.name(), dist.version())?;
for (name, source_path, target_path) in &target_entry_points {
debug!("Installing executable: `{name}`");
// Determine the entry points targets. Use a sorted collection for deterministic output.
let target_entrypoints = dist_entrypoints
.into_iter()
.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, source_path, target_path)
})
.collect::<BTreeSet<_>>();
#[cfg(unix)]
replace_symlink(source_path, target_path).context("Failed to install executable")?;
if target_entrypoints.is_empty() {
writeln!(
printer.stdout(),
"No executables are provided by `{}`",
package.cyan()
)?;
hint_executable_from_dependency(&package, &site_packages, printer)?;
// Clean up the environment we just created.
installed_tools.remove_environment(&package)?;
return Err(anyhow::anyhow!(
"Failed to install entrypoints for `{}`",
package.cyan()
));
}
// Error if we're overwriting an existing entrypoint, unless the user passed `--force`.
if !force {
let mut existing_entrypoints = target_entrypoints
.iter()
.filter(|(_, _, target_path)| target_path.exists())
.peekable();
if existing_entrypoints.peek().is_some() {
// Clean up the environment we just created
installed_tools.remove_environment(name)?;
let existing_entrypoints = existing_entrypoints
// 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_entrypoints.len() == 1 {
("", "exists")
} else {
("s", "exist")
};
bail!(
"Executable{s} already {exists}: {} (use `--force` to overwrite)",
existing_entrypoints
.iter()
.map(|name| name.bold())
.join(", ")
)
}
}
#[cfg(windows)]
if itself.as_ref().is_some_and(|itself| {
std::path::absolute(target_path).is_ok_and(|target| *itself == target)
}) {
self_replace::self_replace(source_path).context("Failed to install entrypoint")?;
} else {
fs_err::copy(source_path, target_path).context("Failed to install entrypoint")?;
}
}
let itself = std::env::current_exe().ok();
let s = if target_entry_points.len() == 1 {
""
} else {
"s"
};
writeln!(
printer.stderr(),
"Installed {} executable{s}: {}",
target_entry_points.len(),
target_entry_points
.iter()
.map(|(name, _, _)| name.bold())
.join(", ")
)?;
let mut names = BTreeSet::new();
for (name, src, target) in target_entrypoints {
debug!("Installing executable: `{name}`");
#[cfg(unix)]
replace_symlink(src, &target).context("Failed to install executable")?;
#[cfg(windows)]
if itself.as_ref().is_some_and(|itself| {
std::path::absolute(&target).is_ok_and(|target| *itself == target)
}) {
self_replace::self_replace(src).context("Failed to install entrypoint")?;
} else {
fs_err::copy(src, &target).context("Failed to install entrypoint")?;
}
names.insert(name.trim_end_matches(EXE_SUFFIX).to_string());
let tool_entry = ToolEntrypoint::new(name, target, package.to_string());
installed_entrypoints.push(tool_entry);
}
let s = if names.len() == 1 { "" } else { "s" };
let from_pkg = if *name == package {
String::new()
} else {
format!(" from `{package}`")
};
writeln!(
printer.stderr(),
"Installed {} executable{s}{from_pkg}: {}",
names.len(),
names.iter().map(|name| name.bold()).join(", ")
)?;
}
debug!("Adding receipt for tool `{name}`");
let tool = Tool::new(
@ -300,45 +323,48 @@ pub(crate) fn finalize_tool_install(
overrides,
build_constraints,
python,
target_entry_points
.into_iter()
.map(|(name, _, target_path)| ToolEntrypoint::new(name, target_path)),
options,
installed_entrypoints,
options.clone(),
);
installed_tools.add_tool_receipt(name, tool)?;
warn_out_of_path(&executable_directory);
Ok(())
}
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.supports_update() {
warn_user!(
warn_user_once!(
"`{}` is not on your PATH. To use installed tools, run `{}` or `{}`.",
executable_directory.simplified_display().cyan(),
command.green(),
"uv tool update-shell".green()
);
} else {
warn_user!(
warn_user_once!(
"`{}` is not on your PATH. To use installed tools, run `{}`.",
executable_directory.simplified_display().cyan(),
command.green()
);
}
} else {
warn_user!(
warn_user_once!(
"`{}` is not on your PATH. To use installed tools, add the directory to your PATH.",
executable_directory.simplified_display().cyan(),
);
}
} else {
warn_user!(
warn_user_once!(
"`{}` is not on your PATH. To use installed tools, add the directory to your PATH.",
executable_directory.simplified_display().cyan(),
);
}
}
Ok(ExitStatus::Success)
}
/// Displays a hint if an executable matching the package name can be found in a dependency of the package.

View file

@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write;
use std::str::FromStr;
@ -48,6 +48,7 @@ pub(crate) async fn install(
editable: bool,
from: Option<String>,
with: &[RequirementsSource],
mut with_executables_from: BTreeSet<PackageName>,
constraints: &[RequirementsSource],
overrides: &[RequirementsSource],
build_constraints: &[RequirementsSource],
@ -112,7 +113,7 @@ pub(crate) async fn install(
};
// Resolve the `--from` requirement.
let from = match &request {
let requirement = match &request {
// Ex) `ruff`
ToolRequest::Package {
executable,
@ -226,6 +227,8 @@ pub(crate) async fn install(
}
};
let package_name = &requirement.name;
// If the user passed, e.g., `ruff@latest`, we need to mark it as upgradable.
let settings = if request.is_latest() {
ResolverInstallerSettings {
@ -233,7 +236,7 @@ pub(crate) async fn install(
upgrade: settings
.resolver
.upgrade
.combine(Upgrade::package(from.name.clone())),
.combine(Upgrade::package(package_name.clone())),
..settings.resolver
},
..settings
@ -247,7 +250,7 @@ pub(crate) async fn install(
ResolverInstallerSettings {
reinstall: settings
.reinstall
.combine(Reinstall::package(from.name.clone())),
.combine(Reinstall::package(package_name.clone())),
..settings
}
} else {
@ -267,7 +270,7 @@ pub(crate) async fn install(
// Resolve the `--from` and `--with` requirements.
let requirements = {
let mut requirements = Vec::with_capacity(1 + with.len());
requirements.push(from.clone());
requirements.push(requirement.clone());
requirements.extend(
resolve_names(
spec.requirements.clone(),
@ -331,16 +334,16 @@ pub(crate) async fn install(
// (If we find existing entrypoints later on, and the tool _doesn't_ exist, we'll avoid removing
// the external tool's entrypoints (without `--force`).)
let (existing_tool_receipt, invalid_tool_receipt) =
match installed_tools.get_tool_receipt(&from.name) {
match installed_tools.get_tool_receipt(package_name) {
Ok(None) => (None, false),
Ok(Some(receipt)) => (Some(receipt), false),
Err(_) => {
// If the tool is not installed properly, remove the environment and continue.
match installed_tools.remove_environment(&from.name) {
match installed_tools.remove_environment(package_name) {
Ok(()) => {
warn_user!(
"Removed existing `{from}` with invalid receipt",
from = from.name.cyan()
"Removed existing `{}` with invalid receipt",
package_name.cyan()
);
}
Err(uv_tool::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {}
@ -354,20 +357,20 @@ pub(crate) async fn install(
let existing_environment =
installed_tools
.get_environment(&from.name, &cache)?
.get_environment(package_name, &cache)?
.filter(|environment| {
if environment.uses(&interpreter) {
trace!(
"Existing interpreter matches the requested interpreter for `{}`: {}",
from.name,
package_name,
environment.interpreter().sys_executable().display()
);
true
} else {
let _ = writeln!(
printer.stderr(),
"Ignoring existing environment for `{from}`: the requested Python interpreter does not match the environment interpreter",
from = from.name.cyan(),
"Ignoring existing environment for `{}`: the requested Python interpreter does not match the environment interpreter",
package_name.cyan(),
);
false
}
@ -392,15 +395,17 @@ pub(crate) async fn install(
{
if *tool_receipt.options() != options {
// ...but the options differ, we need to update the receipt.
installed_tools
.add_tool_receipt(&from.name, tool_receipt.clone().with_options(options))?;
installed_tools.add_tool_receipt(
package_name,
tool_receipt.clone().with_options(options),
)?;
}
// We're done, though we might need to update the receipt.
writeln!(
printer.stderr(),
"`{from}` is already installed",
from = from.cyan()
"`{}` is already installed",
requirement.cyan()
)?;
return Ok(ExitStatus::Success);
@ -556,7 +561,7 @@ pub(crate) async fn install(
},
};
let environment = installed_tools.create_environment(&from.name, interpreter, preview)?;
let environment = installed_tools.create_environment(package_name, interpreter, preview)?;
// At this point, we removed any existing environment, so we should remove any of its
// executables.
@ -583,8 +588,8 @@ pub(crate) async fn install(
.await
.inspect_err(|_| {
// If we failed to sync, remove the newly created environment.
debug!("Failed to sync environment; removing `{}`", from.name);
let _ = installed_tools.remove_environment(&from.name);
debug!("Failed to sync environment; removing `{}`", package_name);
let _ = installed_tools.remove_environment(package_name);
}) {
Ok(environment) => environment,
Err(ProjectError::Operation(err)) => {
@ -596,11 +601,14 @@ pub(crate) async fn install(
}
};
with_executables_from.insert(package_name.clone());
finalize_tool_install(
&environment,
&from.name,
package_name,
with_executables_from,
&installed_tools,
options,
&options,
force || invalid_tool_receipt,
python_request,
requirements,
@ -608,5 +616,7 @@ pub(crate) async fn install(
overrides,
build_constraints,
printer,
)
)?;
Ok(ExitStatus::Success)
}

View file

@ -1,3 +1,4 @@
use std::env::consts::EXE_SUFFIX;
use std::fmt::Write;
use anyhow::{Result, bail};
@ -148,7 +149,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.

View file

@ -1,8 +1,9 @@
use anyhow::Result;
use itertools::Itertools;
use owo_colors::OwoColorize;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write;
use std::str::FromStr;
use tracing::debug;
use uv_cache::Cache;
@ -375,12 +376,20 @@ async fn upgrade_tool(
// existing executables.
remove_entrypoints(&existing_tool_receipt);
let mut entrypoints: BTreeSet<_> = existing_tool_receipt
.entrypoints()
.iter()
.filter_map(|entry| PackageName::from_str(entry.from.as_ref()?).ok())
.collect();
entrypoints.insert(name.clone());
// If we modified the target tool, reinstall the entrypoints.
finalize_tool_install(
&environment,
name,
entrypoints,
installed_tools,
ToolOptions::from(options),
&ToolOptions::from(options),
true,
existing_tool_receipt.python().to_owned(),
existing_tool_receipt.requirements().to_vec(),

View file

@ -1229,21 +1229,19 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
.combine(Refresh::from(args.settings.resolver.upgrade.clone())),
);
let mut requirements = Vec::with_capacity(
args.with.len() + args.with_editable.len() + args.with_requirements.len(),
);
for package in args.with {
requirements.push(RequirementsSource::from_with_package_argument(&package)?);
let mut requirements = Vec::new();
for pkg in args.with {
requirements.push(RequirementsSource::from_with_package_argument(&pkg)?);
}
for package in args.with_editable {
requirements.push(RequirementsSource::from_editable(&package)?);
for pkg in &args.with_executables_from {
requirements.push(RequirementsSource::from_package_name(pkg)?);
}
for pkg in args.with_editable {
requirements.push(RequirementsSource::from_editable(&pkg)?);
}
for path in args.with_requirements {
requirements.push(RequirementsSource::from_requirements_file(path)?);
}
requirements.extend(
args.with_requirements
.into_iter()
.map(RequirementsSource::from_requirements_file)
.collect::<Result<Vec<_>, _>>()?,
);
let constraints = args
.constraints
@ -1266,6 +1264,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
args.editable,
args.from,
&requirements,
args.with_executables_from,
&constraints,
&overrides,
&build_constraints,

View file

@ -1,3 +1,4 @@
use std::collections::BTreeSet;
use std::env::VarError;
use std::num::NonZeroUsize;
use std::path::PathBuf;
@ -587,6 +588,7 @@ pub(crate) struct ToolInstallSettings {
pub(crate) from: Option<String>,
pub(crate) with: Vec<String>,
pub(crate) with_requirements: Vec<PathBuf>,
pub(crate) with_executables_from: BTreeSet<PackageName>,
pub(crate) with_editable: Vec<String>,
pub(crate) constraints: Vec<PathBuf>,
pub(crate) overrides: Vec<PathBuf>,
@ -611,6 +613,7 @@ impl ToolInstallSettings {
with,
with_editable,
with_requirements,
with_executables_from,
constraints,
overrides,
build_constraints,
@ -651,6 +654,7 @@ impl ToolInstallSettings {
.into_iter()
.filter_map(Maybe::into_option)
.collect(),
with_executables_from: with_executables_from.into_iter().collect(),
constraints: constraints
.into_iter()
.filter_map(Maybe::into_option)