Support installing additional executables in `uv tool install` (#14014)

Close #6314

## Summary

Continuing from #7592. Created a new PR to rebase the old branch with
`main`, cleaned up test errors, and improved readability.

## Test Plan

Same test cases as in #7592.

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
Aaron Ang 2025-07-30 12:50:24 -07:00 committed by GitHub
parent e176e17144
commit 3df972f18a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 567 additions and 228 deletions

View File

@ -4490,6 +4490,10 @@ pub struct ToolInstallArgs {
#[arg(long)]
pub with_editable: Vec<comma::CommaSeparatedRequirements>,
/// Install executables from the following packages.
#[arg(long)]
pub with_executables_from: Vec<comma::CommaSeparatedRequirements>,
/// Constrain versions using the given requirements files.
///
/// Constraints files are `requirements.txt`-like files that only control the _version_ of a

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,10 @@ impl Tool {
overrides: Vec<Requirement>,
build_constraints: Vec<Requirement>,
python: Option<PythonRequest>,
entrypoints: impl Iterator<Item = ToolEntrypoint>,
entrypoints: impl IntoIterator<Item = ToolEntrypoint>,
options: ToolOptions,
) -> Self {
let mut entrypoints: Vec<_> = entrypoints.collect();
let mut entrypoints: Vec<_> = entrypoints.into_iter().collect();
entrypoints.sort();
Self {
requirements,
@ -345,8 +346,15 @@ 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: &str, install_path: PathBuf, from: String) -> Self {
let name = name
.trim_end_matches(std::env::consts::EXE_SUFFIX)
.to_string();
Self {
name,
install_path,
from: Some(from),
}
}
/// Returns the TOML table for this entrypoint.
@ -358,6 +366,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,12 @@
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},
ffi::OsString,
fmt::Write,
path::Path,
};
use tracing::{debug, warn};
use uv_cache::Cache;
use uv_client::BaseClientBuilder;
@ -22,12 +25,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 +172,9 @@ pub(crate) async fn refine_interpreter(
pub(crate) fn finalize_tool_install(
environment: &PythonEnvironment,
name: &PackageName,
entrypoints: &[PackageName],
installed_tools: &InstalledTools,
options: ToolOptions,
options: &ToolOptions,
force: bool,
python: Option<PythonRequest>,
requirements: Vec<Requirement>,
@ -178,120 +182,152 @@ 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
.iter()
.filter(|pkg| *pkg != name)
.collect::<BTreeSet<_>>()
// Then install the root package last
.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<_>>();
.chain(std::iter::once(name));
if target_entry_points.is_empty() {
writeln!(
printer.stdout(),
"No executables are provided by package `{from}`; removing tool",
from = name.cyan()
)?;
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}`");
}
hint_executable_from_dependency(name, &site_packages, printer)?;
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())?;
// Clean up the environment we just created.
installed_tools.remove_environment(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<_>>();
return Ok(ExitStatus::Failure);
}
if target_entrypoints.is_empty() {
// If package is not the root package, suggest to install it as a dependency.
if package != name {
writeln!(
printer.stdout(),
"No executables are provided by package `{}`\n{}{} Use `--with {}` to include `{}` as a dependency without installing its executables.",
package.cyan(),
"hint".bold().cyan(),
":".bold(),
package.cyan(),
package.cyan(),
)?;
continue;
}
// 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
// For the root package, this is a fatal error
writeln!(
printer.stdout(),
"No executables are provided by package `{}`; removing tool",
package.cyan()
)?;
hint_executable_from_dependency(package, &site_packages, printer)?;
// 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(", ")
)
return Err(anyhow::anyhow!(
"Failed to install entrypoints for `{}`",
package.cyan()
));
}
}
#[cfg(windows)]
let itself = std::env::current_exe().ok();
// 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)?;
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")?;
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")?;
}
let tool_entry = ToolEntrypoint::new(&name, target, package.to_string());
names.insert(tool_entry.name.clone());
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 +336,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

@ -50,6 +50,7 @@ pub(crate) async fn install(
constraints: &[RequirementsSource],
overrides: &[RequirementsSource],
build_constraints: &[RequirementsSource],
entrypoints: &[PackageName],
python: Option<String>,
install_mirrors: PythonInstallMirrors,
force: bool,
@ -113,7 +114,7 @@ pub(crate) async fn install(
};
// Resolve the `--from` requirement.
let from = match &request {
let requirement = match &request {
// Ex) `ruff`
ToolRequest::Package {
executable,
@ -219,14 +220,16 @@ pub(crate) async fn install(
}
// Ex) `python`
ToolRequest::Python { .. } => {
return Err(anyhow::anyhow!(
bail!(
"Cannot install Python with `{}`. Did you mean to use `{}`?",
"uv tool install".cyan(),
"uv python install".cyan(),
));
);
}
};
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 {
@ -234,7 +237,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
@ -248,7 +251,7 @@ pub(crate) async fn install(
ResolverInstallerSettings {
reinstall: settings
.reinstall
.combine(Reinstall::package(from.name.clone())),
.combine(Reinstall::package(package_name.clone())),
..settings
}
} else {
@ -268,7 +271,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(),
@ -332,16 +335,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 => {}
@ -355,20 +358,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
}
@ -393,15 +396,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);
@ -560,7 +565,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.
@ -587,8 +592,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)) => {
@ -602,9 +607,10 @@ pub(crate) async fn install(
finalize_tool_install(
&environment,
&from.name,
package_name,
entrypoints,
&installed_tools,
options,
&options,
force || invalid_tool_receipt,
python_request,
requirements,
@ -612,5 +618,7 @@ pub(crate) async fn install(
overrides,
build_constraints,
printer,
)
)?;
Ok(ExitStatus::Success)
}

View File

@ -3,6 +3,7 @@ use itertools::Itertools;
use owo_colors::{AnsiColors, OwoColorize};
use std::collections::BTreeMap;
use std::fmt::Write;
use std::str::FromStr;
use tracing::debug;
use uv_cache::Cache;
@ -372,12 +373,19 @@ async fn upgrade_tool(
// existing executables.
remove_entrypoints(&existing_tool_receipt);
let entrypoints: Vec<_> = existing_tool_receipt
.entrypoints()
.iter()
.filter_map(|entry| PackageName::from_str(entry.from.as_ref()?).ok())
.collect();
// 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

@ -1258,21 +1258,35 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
.combine(Refresh::from(args.settings.resolver.upgrade.clone())),
);
let mut entrypoints = Vec::with_capacity(args.with_executables_from.len());
let mut requirements = Vec::with_capacity(
args.with.len() + args.with_editable.len() + args.with_requirements.len(),
args.with.len()
+ args.with_editable.len()
+ args.with_requirements.len()
+ args.with_executables_from.len(),
);
for package in args.with {
requirements.push(RequirementsSource::from_with_package_argument(&package)?);
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_editable {
requirements.push(RequirementsSource::from_editable(&pkg)?);
}
for path in args.with_requirements {
requirements.push(RequirementsSource::from_requirements_file(path)?);
}
for pkg in &args.with_executables_from {
let source = RequirementsSource::from_with_package_argument(pkg)?;
let RequirementsSource::Package(RequirementsTxtRequirement::Named(requirement)) =
&source
else {
bail!(
"Expected a named package for `--with-executables-from`, but got: {}",
source.to_string().cyan()
)
};
entrypoints.push(requirement.name.clone());
requirements.push(source);
}
requirements.extend(
args.with_requirements
.into_iter()
.map(RequirementsSource::from_requirements_file)
.collect::<Result<Vec<_>, _>>()?,
);
let constraints = args
.constraints
@ -1298,6 +1312,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
&constraints,
&overrides,
&build_constraints,
&entrypoints,
args.python,
args.install_mirrors,
args.force,

View File

@ -598,6 +598,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: Vec<String>,
pub(crate) with_editable: Vec<String>,
pub(crate) constraints: Vec<PathBuf>,
pub(crate) overrides: Vec<PathBuf>,
@ -622,6 +623,7 @@ impl ToolInstallSettings {
with,
with_editable,
with_requirements,
with_executables_from,
constraints,
overrides,
build_constraints,
@ -662,6 +664,10 @@ impl ToolInstallSettings {
.into_iter()
.filter_map(Maybe::into_option)
.collect(),
with_executables_from: with_executables_from
.into_iter()
.flat_map(CommaSeparatedRequirements::into_iter)
.collect(),
constraints: constraints
.into_iter()
.filter_map(Maybe::into_option)

View File

@ -3424,6 +3424,7 @@ fn resolve_tool() -> anyhow::Result<()> {
from: None,
with: [],
with_requirements: [],
with_executables_from: [],
with_editable: [],
constraints: [],
overrides: [],

View File

@ -82,8 +82,8 @@ fn tool_install() {
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -168,7 +168,7 @@ fn tool_install() {
[tool]
requirements = [{ name = "flask" }]
entrypoints = [
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask" },
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask", from = "flask" },
]
[tool.options]
@ -382,8 +382,8 @@ fn tool_install_with_compatible_build_constraints() -> Result<()> {
]
build-constraint-dependencies = [{ name = "setuptools", specifier = ">=40" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -450,7 +450,7 @@ fn tool_install_suggest_other_packages_with_executable() {
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r"
success: false
exit_code: 1
exit_code: 2
----- stdout -----
No executables are provided by package `fastapi`; removing tool
hint: An executable with the name `fastapi` is available via dependency `fastapi-cli`.
@ -494,6 +494,7 @@ fn tool_install_suggest_other_packages_with_executable() {
+ uvicorn==0.29.0
+ watchfiles==0.21.0
+ websockets==12.0
error: Failed to install entrypoints for `fastapi`
");
}
@ -565,8 +566,8 @@ fn tool_install_version() {
[tool]
requirements = [{ name = "black", specifier = "==24.2.0" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -649,7 +650,7 @@ fn tool_install_editable() {
[tool]
requirements = [{ name = "black", editable = "[WORKSPACE]/scripts/packages/black_editable" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
]
[tool.options]
@ -690,7 +691,7 @@ fn tool_install_editable() {
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
]
[tool.options]
@ -733,8 +734,8 @@ fn tool_install_editable() {
[tool]
requirements = [{ name = "black", specifier = "==24.2.0" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -781,8 +782,8 @@ fn tool_install_remove_on_empty() -> Result<()> {
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -823,7 +824,7 @@ fn tool_install_remove_on_empty() -> Result<()> {
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: false
exit_code: 1
exit_code: 2
----- stdout -----
No executables are provided by package `black`; removing tool
@ -839,6 +840,7 @@ fn tool_install_remove_on_empty() -> Result<()> {
- packaging==24.0
- pathspec==0.12.1
- platformdirs==4.2.0
error: Failed to install entrypoints for `black`
");
// Re-request `black`. It should reinstall, without requiring `--force`.
@ -871,8 +873,8 @@ fn tool_install_remove_on_empty() -> Result<()> {
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -949,7 +951,7 @@ fn tool_install_editable_from() {
[tool]
requirements = [{ name = "black", editable = "[WORKSPACE]/scripts/packages/black_editable" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
]
[tool.options]
@ -1101,8 +1103,8 @@ fn tool_install_already_installed() {
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -1137,8 +1139,8 @@ fn tool_install_already_installed() {
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -1428,8 +1430,8 @@ fn tool_install_force() {
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -1466,8 +1468,8 @@ fn tool_install_force() {
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -1651,7 +1653,7 @@ fn tool_install_no_entrypoints() {
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: false
exit_code: 1
exit_code: 2
----- stdout -----
No executables are provided by package `iniconfig`; removing tool
@ -1660,6 +1662,7 @@ fn tool_install_no_entrypoints() {
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
error: Failed to install entrypoints for `iniconfig`
");
// Ensure the tool environment is not created.
@ -1794,8 +1797,8 @@ fn tool_install_unnamed_package() {
[tool]
requirements = [{ name = "black", url = "https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -1909,8 +1912,8 @@ fn tool_install_unnamed_from() {
[tool]
requirements = [{ name = "black", url = "https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -2003,8 +2006,8 @@ fn tool_install_unnamed_with() {
{ name = "iniconfig", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" },
]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -2072,8 +2075,8 @@ fn tool_install_requirements_txt() {
{ name = "iniconfig" },
]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -2117,8 +2120,8 @@ fn tool_install_requirements_txt() {
{ name = "idna" },
]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -2181,8 +2184,8 @@ fn tool_install_requirements_txt_arguments() {
{ name = "idna" },
]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -2295,8 +2298,8 @@ fn tool_install_upgrade() {
[tool]
requirements = [{ name = "black", specifier = "==24.1.1" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -2329,8 +2332,8 @@ fn tool_install_upgrade() {
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -2369,8 +2372,8 @@ fn tool_install_upgrade() {
{ name = "iniconfig", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" },
]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -2409,8 +2412,8 @@ fn tool_install_upgrade() {
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -2878,7 +2881,7 @@ fn tool_install_malformed_dist_info() {
[tool]
requirements = [{ name = "executable-application" }]
entrypoints = [
{ name = "app", install-path = "[TEMP_DIR]/bin/app" },
{ name = "app", install-path = "[TEMP_DIR]/bin/app", from = "executable-application" },
]
[tool.options]
@ -2958,7 +2961,7 @@ fn tool_install_settings() {
[tool]
requirements = [{ name = "flask", specifier = ">=3" }]
entrypoints = [
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask" },
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask", from = "flask" },
]
[tool.options]
@ -2991,7 +2994,7 @@ fn tool_install_settings() {
[tool]
requirements = [{ name = "flask", specifier = ">=3" }]
entrypoints = [
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask" },
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask", from = "flask" },
]
[tool.options]
@ -3031,7 +3034,7 @@ fn tool_install_settings() {
[tool]
requirements = [{ name = "flask", specifier = ">=3" }]
entrypoints = [
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask" },
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask", from = "flask" },
]
[tool.options]
@ -3080,8 +3083,8 @@ fn tool_install_at_version() {
[tool]
requirements = [{ name = "black", specifier = "==24.1.0" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -3146,8 +3149,8 @@ fn tool_install_at_latest() {
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -3192,7 +3195,7 @@ fn tool_install_from_at_latest() {
[tool]
requirements = [{ name = "executable-application" }]
entrypoints = [
{ name = "app", install-path = "[TEMP_DIR]/bin/app" },
{ name = "app", install-path = "[TEMP_DIR]/bin/app", from = "executable-application" },
]
[tool.options]
@ -3237,7 +3240,7 @@ fn tool_install_from_at_version() {
[tool]
requirements = [{ name = "executable-application", specifier = "==0.2.0" }]
entrypoints = [
{ name = "app", install-path = "[TEMP_DIR]/bin/app" },
{ name = "app", install-path = "[TEMP_DIR]/bin/app", from = "executable-application" },
]
[tool.options]
@ -3286,8 +3289,8 @@ fn tool_install_at_latest_upgrade() {
[tool]
requirements = [{ name = "black", specifier = "==24.1.1" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -3320,8 +3323,8 @@ fn tool_install_at_latest_upgrade() {
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -3357,8 +3360,8 @@ fn tool_install_at_latest_upgrade() {
[tool]
requirements = [{ name = "black" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -3419,8 +3422,8 @@ fn tool_install_constraints() -> Result<()> {
{ name = "anyio", specifier = ">=3" },
]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -3526,8 +3529,8 @@ fn tool_install_overrides() -> Result<()> {
{ name = "anyio", specifier = ">=3" },
]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -3700,7 +3703,7 @@ fn tool_install_credentials() {
[tool]
requirements = [{ name = "executable-application" }]
entrypoints = [
{ name = "app", install-path = "[TEMP_DIR]/bin/app" },
{ name = "app", install-path = "[TEMP_DIR]/bin/app", from = "executable-application" },
]
[tool.options]
@ -3789,7 +3792,7 @@ fn tool_install_default_credentials() -> Result<()> {
[tool]
requirements = [{ name = "executable-application" }]
entrypoints = [
{ name = "app", install-path = "[TEMP_DIR]/bin/app" },
{ name = "app", install-path = "[TEMP_DIR]/bin/app", from = "executable-application" },
]
[tool.options]
@ -3832,3 +3835,136 @@ fn tool_install_default_credentials() -> Result<()> {
Ok(())
}
/// Test installing a tool with `--with-executables-from`.
#[test]
fn tool_install_with_executables_from() {
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");
uv_snapshot!(context.filters(), context.tool_install()
.arg("--with-executables-from")
.arg("ansible-core,black")
.arg("ansible==9.3.0")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str())
.env("PATH", bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ ansible==9.3.0
+ ansible-core==2.16.4
+ black==24.3.0
+ cffi==1.16.0
+ click==8.1.7
+ cryptography==42.0.5
+ jinja2==3.1.3
+ markupsafe==2.1.5
+ mypy-extensions==1.0.0
+ packaging==24.0
+ pathspec==0.12.1
+ platformdirs==4.2.0
+ pycparser==2.21
+ pyyaml==6.0.1
+ resolvelib==1.0.1
Installed 11 executables from `ansible-core`: ansible, ansible-config, ansible-connection, ansible-console, ansible-doc, ansible-galaxy, ansible-inventory, ansible-playbook, ansible-pull, ansible-test, ansible-vault
Installed 2 executables from `black`: black, blackd
Installed 1 executable: ansible-community
");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(fs_err::read_to_string(tool_dir.join("ansible").join("uv-receipt.toml")).unwrap(), @r###"
[tool]
requirements = [
{ name = "ansible", specifier = "==9.3.0" },
{ name = "ansible-core" },
{ name = "black" },
]
entrypoints = [
{ name = "ansible", install-path = "[TEMP_DIR]/bin/ansible", from = "ansible-core" },
{ name = "ansible-community", install-path = "[TEMP_DIR]/bin/ansible-community", from = "ansible" },
{ name = "ansible-config", install-path = "[TEMP_DIR]/bin/ansible-config", from = "ansible-core" },
{ name = "ansible-connection", install-path = "[TEMP_DIR]/bin/ansible-connection", from = "ansible-core" },
{ name = "ansible-console", install-path = "[TEMP_DIR]/bin/ansible-console", from = "ansible-core" },
{ name = "ansible-doc", install-path = "[TEMP_DIR]/bin/ansible-doc", from = "ansible-core" },
{ name = "ansible-galaxy", install-path = "[TEMP_DIR]/bin/ansible-galaxy", from = "ansible-core" },
{ name = "ansible-inventory", install-path = "[TEMP_DIR]/bin/ansible-inventory", from = "ansible-core" },
{ name = "ansible-playbook", install-path = "[TEMP_DIR]/bin/ansible-playbook", from = "ansible-core" },
{ name = "ansible-pull", install-path = "[TEMP_DIR]/bin/ansible-pull", from = "ansible-core" },
{ name = "ansible-test", install-path = "[TEMP_DIR]/bin/ansible-test", from = "ansible-core" },
{ name = "ansible-vault", install-path = "[TEMP_DIR]/bin/ansible-vault", from = "ansible-core" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
exclude-newer = "2024-03-25T00:00:00Z"
"###);
});
uv_snapshot!(context.filters(), context.tool_uninstall()
.arg("ansible")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str())
.env("PATH", bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Uninstalled 14 executables: ansible, ansible-community, ansible-config, ansible-connection, ansible-console, ansible-doc, ansible-galaxy, ansible-inventory, ansible-playbook, ansible-pull, ansible-test, ansible-vault, black, blackd
"###);
}
/// Test installing a tool with `--with-executables-from`, but the package has no entrypoints.
#[test]
fn tool_install_with_executables_from_no_entrypoints() {
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");
// Try to install flask with executables from requests (which has no executables)
uv_snapshot!(context.filters(), context.tool_install()
.arg("--with-executables-from")
.arg("requests")
.arg("flask")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str())
.env("PATH", bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
No executables are provided by package `requests`
hint: Use `--with requests` to include `requests` as a dependency without installing its executables.
----- stderr -----
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ blinker==1.7.0
+ certifi==2024.2.2
+ charset-normalizer==3.3.2
+ click==8.1.7
+ flask==3.0.2
+ idna==3.6
+ itsdangerous==2.1.2
+ jinja2==3.1.3
+ markupsafe==2.1.5
+ requests==2.31.0
+ urllib3==2.2.1
+ werkzeug==3.0.1
Installed 1 executable: flask
"###);
}

View File

@ -89,8 +89,8 @@ fn tool_list_paths_windows() {
exit_code: 0
----- stdout -----
black v24.2.0 ([TEMP_DIR]\tools\black)
- black.exe ([TEMP_DIR]\bin\black.exe)
- blackd.exe ([TEMP_DIR]\bin\blackd.exe)
- black ([TEMP_DIR]\bin\black.exe)
- blackd ([TEMP_DIR]\bin\blackd.exe)
----- stderr -----
"###);
@ -218,8 +218,8 @@ fn tool_list_deprecated() -> Result<()> {
[tool]
requirements = [{ name = "black", specifier = "==24.2.0" }]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
[tool.options]
@ -234,8 +234,8 @@ fn tool_list_deprecated() -> Result<()> {
[tool]
requirements = ["black==24.2.0"]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
"#,
)?;
@ -261,8 +261,8 @@ fn tool_list_deprecated() -> Result<()> {
[tool]
requirements = ["black<>24.2.0"]
entrypoints = [
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
]
"#,
)?;

View File

@ -835,3 +835,71 @@ fn tool_upgrade_python_with_all() {
assert_snapshot!(lines[lines.len() - 3], @"version_info = 3.12.[X]");
});
}
/// Upgrade a tool together with any additional entrypoints from other
/// packages.
#[test]
fn test_tool_upgrade_additional_entrypoints() {
let context = TestContext::new_with_versions(&["3.11", "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");
// Install `babel` entrypoint, and all additional ones from `black` too.
uv_snapshot!(context.filters(), context.tool_install()
.arg("--python")
.arg("3.11")
.arg("--with-executables-from")
.arg("black")
.arg("babel==2.14.0")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str())
.env("PATH", bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ babel==2.14.0
+ black==24.3.0
+ click==8.1.7
+ mypy-extensions==1.0.0
+ packaging==24.0
+ pathspec==0.12.1
+ platformdirs==4.2.0
Installed 2 executables from `black`: black, blackd
Installed 1 executable: pybabel
");
// Upgrade python, and make sure that all the entrypoints above get
// re-installed.
uv_snapshot!(context.filters(), context.tool_upgrade()
.arg("--python")
.arg("3.12")
.arg("babel")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str())
.env("PATH", bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ babel==2.14.0
+ black==24.3.0
+ click==8.1.7
+ mypy-extensions==1.0.0
+ packaging==24.0
+ pathspec==0.12.1
+ platformdirs==4.2.0
Installed 2 executables from `black`: black, blackd
Installed 1 executable: pybabel
Upgraded tool environment for `babel` to Python 3.12
");
}

View File

@ -209,6 +209,40 @@ $ uvx -w <extra-package> <tool-package>
If the requested version conflicts with the requirements of the tool package, package resolution
will fail and the command will error.
## Installing executables from additional packages
When installing a tool, you may want to include executables from additional packages in the same
tool environment. This is useful when you have related tools that work together or when you want to
install multiple executables that share dependencies.
The `--with-executables-from` option allows you to specify additional packages whose executables
should be installed alongside the main tool:
```console
$ uv tool install --with-executables-from <package1>,<package2> <tool-package>
```
For example, to install Ansible along with executables from `ansible-core` and `ansible-lint`:
```console
$ uv tool install --with-executables-from ansible-core,ansible-lint ansible
```
This will install all executables from the `ansible`, `ansible-core`, and `ansible-lint` packages
into the same tool environment, making them all available on the `PATH`.
The `--with-executables-from` option can be combined with other installation options:
```console
$ uv tool install --with-executables-from ansible-core --with mkdocs-material ansible
```
Note that `--with-executables-from` differs from `--with` in that:
- `--with` includes additional packages as dependencies but does not install their executables
- `--with-executables-from` includes both the packages as dependencies and installs their
executables
## Python versions
Each tool environment is linked to a specific Python version. This uses the same Python version

View File

@ -213,6 +213,14 @@ As with `uvx`, installations can include additional packages:
$ uv tool install mkdocs --with mkdocs-material
```
Multiple related executables can be installed together in the same tool environment, using the
`--with-executables-from` flag. For example, the following will install the executables from
`ansible`, plus those ones provided by `ansible-core` and `ansible-lint`:
```console
$ uv tool install --with-executables-from ansible-core,ansible-lint ansible
```
## Upgrading tools
To upgrade a tool, use `uv tool upgrade`:

View File

@ -2198,6 +2198,7 @@ uv tool install [OPTIONS] <PACKAGE>
<p>You can configure fine-grained logging using the <code>RUST_LOG</code> environment variable. (<a href="https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives">https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives</a>)</p>
</dd><dt id="uv-tool-install--with"><a href="#uv-tool-install--with"><code>--with</code></a>, <code>-w</code> <i>with</i></dt><dd><p>Include the following additional requirements</p>
</dd><dt id="uv-tool-install--with-editable"><a href="#uv-tool-install--with-editable"><code>--with-editable</code></a> <i>with-editable</i></dt><dd><p>Include the given packages in editable mode</p>
</dd><dt id="uv-tool-install--with-executables-from"><a href="#uv-tool-install--with-executables-from"><code>--with-executables-from</code></a> <i>with-executables-from</i></dt><dd><p>Install executables from the following packages</p>
</dd><dt id="uv-tool-install--with-requirements"><a href="#uv-tool-install--with-requirements"><code>--with-requirements</code></a> <i>with-requirements</i></dt><dd><p>Include all requirements listed in the given <code>requirements.txt</code> files</p>
</dd></dl>