From 3df972f18a0fca92c278a0ec1492e9c29314118d Mon Sep 17 00:00:00 2001 From: Aaron Ang <67321817+aaron-ang@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:50:24 -0700 Subject: [PATCH] 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 --- crates/uv-cli/src/lib.rs | 4 + crates/uv-tool/src/tool.rs | 19 +- crates/uv/src/commands/tool/common.rs | 261 +++++++++++++---------- crates/uv/src/commands/tool/install.rs | 56 ++--- crates/uv/src/commands/tool/upgrade.rs | 10 +- crates/uv/src/lib.rs | 37 +++- crates/uv/src/settings.rs | 6 + crates/uv/tests/it/show_settings.rs | 1 + crates/uv/tests/it/tool_install.rs | 274 ++++++++++++++++++------- crates/uv/tests/it/tool_list.rs | 16 +- crates/uv/tests/it/tool_upgrade.rs | 68 ++++++ docs/concepts/tools.md | 34 +++ docs/guides/tools.md | 8 + docs/reference/cli.md | 1 + 14 files changed, 567 insertions(+), 228 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index e5bd3b0e2..f80abc06d 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4490,6 +4490,10 @@ pub struct ToolInstallArgs { #[arg(long)] pub with_editable: Vec, + /// Install executables from the following packages. + #[arg(long)] + pub with_executables_from: Vec, + /// Constrain versions using the given requirements files. /// /// Constraints files are `requirements.txt`-like files that only control the _version_ of a diff --git a/crates/uv-tool/src/tool.rs b/crates/uv-tool/src/tool.rs index cce3a2f58..0fbf59e22 100644 --- a/crates/uv-tool/src/tool.rs +++ b/crates/uv-tool/src/tool.rs @@ -103,6 +103,7 @@ impl TryFrom for Tool { pub struct ToolEntrypoint { pub name: String, pub install_path: PathBuf, + pub from: Option, } impl Display for ToolEntrypoint { @@ -166,10 +167,10 @@ impl Tool { overrides: Vec, build_constraints: Vec, python: Option, - entrypoints: impl Iterator, + entrypoints: impl IntoIterator, 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 } } diff --git a/crates/uv/src/commands/tool/common.rs b/crates/uv/src/commands/tool/common.rs index 5647afa32..8a45a3153 100644 --- a/crates/uv/src/commands/tool/common.rs +++ b/crates/uv/src/commands/tool/common.rs @@ -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, requirements: Vec, @@ -178,120 +182,152 @@ pub(crate) fn finalize_tool_install( overrides: Vec, build_constraints: Vec, printer: Printer, -) -> anyhow::Result { - 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::>() + // 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::>(); + .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::>(); - 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::>(); - 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::>(); + 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. diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 6528f61d2..4917934c4 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -50,6 +50,7 @@ pub(crate) async fn install( constraints: &[RequirementsSource], overrides: &[RequirementsSource], build_constraints: &[RequirementsSource], + entrypoints: &[PackageName], python: Option, 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) } diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index f7bce3197..cac86c149 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -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(), diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 0d5163e4d..db8b25084 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1258,21 +1258,35 @@ async fn run(mut cli: Cli) -> Result { .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::, _>>()?, - ); let constraints = args .constraints @@ -1298,6 +1312,7 @@ async fn run(mut cli: Cli) -> Result { &constraints, &overrides, &build_constraints, + &entrypoints, args.python, args.install_mirrors, args.force, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 7746f0667..2e2623357 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -598,6 +598,7 @@ pub(crate) struct ToolInstallSettings { pub(crate) from: Option, pub(crate) with: Vec, pub(crate) with_requirements: Vec, + pub(crate) with_executables_from: Vec, pub(crate) with_editable: Vec, pub(crate) constraints: Vec, pub(crate) overrides: Vec, @@ -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) diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 6775f2e3d..ff9c4383f 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -3424,6 +3424,7 @@ fn resolve_tool() -> anyhow::Result<()> { from: None, with: [], with_requirements: [], + with_executables_from: [], with_editable: [], constraints: [], overrides: [], diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index 0af2510fb..cd080f404 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -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 + "###); +} diff --git a/crates/uv/tests/it/tool_list.rs b/crates/uv/tests/it/tool_list.rs index 9268118ca..cb767d457 100644 --- a/crates/uv/tests/it/tool_list.rs +++ b/crates/uv/tests/it/tool_list.rs @@ -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" }, ] "#, )?; diff --git a/crates/uv/tests/it/tool_upgrade.rs b/crates/uv/tests/it/tool_upgrade.rs index 70309f04d..74ae4befd 100644 --- a/crates/uv/tests/it/tool_upgrade.rs +++ b/crates/uv/tests/it/tool_upgrade.rs @@ -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 + "); +} diff --git a/docs/concepts/tools.md b/docs/concepts/tools.md index 7c5eb9564..b721dfa8e 100644 --- a/docs/concepts/tools.md +++ b/docs/concepts/tools.md @@ -209,6 +209,40 @@ $ uvx -w 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 , +``` + +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 diff --git a/docs/guides/tools.md b/docs/guides/tools.md index b281b89b2..e86bcf627 100644 --- a/docs/guides/tools.md +++ b/docs/guides/tools.md @@ -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`: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 5d4ffea68..01b5184c8 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2198,6 +2198,7 @@ uv tool install [OPTIONS]

You can configure fine-grained logging using the RUST_LOG environment variable. (https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives)

--with, -w with

Include the following additional requirements

--with-editable with-editable

Include the given packages in editable mode

+
--with-executables-from with-executables-from

Install executables from the following packages

--with-requirements with-requirements

Include all requirements listed in the given requirements.txt files