From e40e3ffd018e76f45371727b54bb20c2d492500c Mon Sep 17 00:00:00 2001 From: Luca BRUNO Date: Fri, 20 Sep 2024 19:46:33 +0200 Subject: [PATCH] wip: Support installing additional executables in `uv tool install` --- crates/uv-cli/src/lib.rs | 4 ++ crates/uv-tool/src/tool.rs | 22 +++++-- crates/uv/src/commands/tool/common.rs | 50 ++++++++-------- crates/uv/src/commands/tool/install.rs | 54 ++++++++++++++++-- crates/uv/src/commands/tool/upgrade.rs | 14 ++++- crates/uv/src/lib.rs | 9 ++- crates/uv/src/settings.rs | 3 + crates/uv/tests/tool_install.rs | 79 ++++++++++++++++++++++++++ docs/reference/cli.md | 2 + 9 files changed, 203 insertions(+), 34 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 587f0d833049..2397539e2469 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3271,6 +3271,10 @@ pub struct ToolInstallArgs { #[arg(long)] pub with: Vec, + /// Additionally include entrypoints from the following packages. + #[arg(long)] + pub i_want_ponies: Vec, + /// Run all requirements listed in the given `requirements.txt` files. #[arg(long, value_parser = parse_maybe_file_path)] pub with_requirements: Vec>, diff --git a/crates/uv-tool/src/tool.rs b/crates/uv-tool/src/tool.rs index 0eb47b763010..956ec4169a77 100644 --- a/crates/uv-tool/src/tool.rs +++ b/crates/uv-tool/src/tool.rs @@ -84,6 +84,8 @@ impl TryFrom for Tool { pub struct ToolEntrypoint { pub name: String, pub install_path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + pub from: Option, } /// Format an array so that each element is on its own line and has a trailing comma. @@ -118,10 +120,10 @@ impl Tool { pub fn new( requirements: Vec, python: Option, - entrypoints: impl Iterator, + entrypoints: Vec, options: ToolOptions, ) -> Self { - let mut entrypoints: Vec<_> = entrypoints.collect(); + let mut entrypoints = entrypoints; entrypoints.sort(); Self { requirements, @@ -204,12 +206,21 @@ impl Tool { pub fn options(&self) -> &ToolOptions { &self.options } + + /// Consume a given `Tool` returning its entrypoints. + pub fn into_entrypoints(self) -> Vec { + self.entrypoints + } } impl ToolEntrypoint { /// Create a new [`ToolEntrypoint`]. - pub fn new(name: String, install_path: PathBuf) -> Self { - Self { name, install_path } + pub fn new(name: String, install_path: PathBuf, from: String) -> Self { + Self { + name, + install_path, + from: Some(from), + } } /// Returns the TOML table for this entrypoint. @@ -221,6 +232,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 d6b53c8e74f4..0019709354f7 100644 --- a/crates/uv/src/commands/tool/common.rs +++ b/crates/uv/src/commands/tool/common.rs @@ -1,4 +1,5 @@ use std::fmt::Write; +use std::path::Path; use std::{collections::BTreeSet, ffi::OsString}; use anyhow::{bail, Context}; @@ -16,10 +17,9 @@ use uv_installer::SitePackages; use uv_python::PythonEnvironment; use uv_settings::ToolOptions; use uv_shell::Shell; -use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint}; +use uv_tool::{entrypoint_paths, InstalledTools, Tool, ToolEntrypoint}; use uv_warnings::warn_user; -use crate::commands::ExitStatus; use crate::printer::Printer; /// Return all packages which contain an executable with the given name. @@ -64,25 +64,23 @@ pub(crate) fn remove_entrypoints(tool: &Tool) { /// Installs tool executables for a given package and handles any conflicts. pub(crate) fn install_executables( environment: &PythonEnvironment, + tool_name: &PackageName, name: &PackageName, installed_tools: &InstalledTools, + existing_entrypoints: &[ToolEntrypoint], + executable_directory: &Path, options: ToolOptions, force: bool, python: Option, requirements: Vec, printer: Printer, -) -> anyhow::Result { +) -> 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 = find_executable_directory()?; - fs_err::create_dir_all(&executable_directory) - .context("Failed to create executable directory")?; - debug!( "Installing tool executables into: {}", executable_directory.user_display() @@ -120,7 +118,10 @@ pub(crate) fn install_executables( // Clean up the environment we just created. installed_tools.remove_environment(name)?; - return Ok(ExitStatus::Failure); + return Err(anyhow::anyhow!( + "Failed to install entrypoints for `{from}`", + from = name.cyan() + )); } // Check if they exist, before installing @@ -129,7 +130,7 @@ pub(crate) fn install_executables( .filter(|(_, _, target_path)| target_path.exists()) .peekable(); - // Ignore any existing entrypoints if the user passed `--force`, or the existing recept was + // Ignore any existing entrypoints if the user passed `--force`, or the existing receipt was // broken. if force { for (name, _, target) in existing_entry_points { @@ -138,7 +139,7 @@ pub(crate) fn install_executables( } } else if existing_entry_points.peek().is_some() { // Clean up the environment we just created - installed_tools.remove_environment(name)?; + installed_tools.remove_environment(tool_name)?; let existing_entry_points = existing_entry_points // SAFETY: We know the target has a filename because we just constructed it above @@ -182,20 +183,24 @@ pub(crate) fn install_executables( )?; debug!("Adding receipt for tool `{name}`"); - let tool = Tool::new( - requirements.into_iter().collect(), - python, - target_entry_points - .into_iter() - .map(|(name, _, target_path)| ToolEntrypoint::new(name, target_path)), - options, - ); - installed_tools.add_tool_receipt(name, tool)?; + let mut entrypoints = existing_entrypoints.to_vec(); + for (entry, _, target_path) in target_entry_points { + entrypoints.push(ToolEntrypoint::new(entry, target_path, name.to_string())); + } + let tool = Tool::new(requirements, python, entrypoints, options); + installed_tools.add_tool_receipt(tool_name, tool.clone())?; + + warn_out_of_path(executable_directory); + + Ok(tool) +} + +pub(crate) fn warn_out_of_path(executable_directory: &Path) { // If the executable directory isn't on the user's PATH, warn. - if !Shell::contains_path(&executable_directory) { + if !Shell::contains_path(executable_directory) { if let Some(shell) = Shell::from_env() { - if let Some(command) = shell.prepend_path(&executable_directory) { + if let Some(command) = shell.prepend_path(executable_directory) { if shell.configuration_files().is_empty() { warn_user!( "`{}` is not on your PATH. To use installed tools, run `{}`.", @@ -223,7 +228,6 @@ pub(crate) fn install_executables( ); } } - Ok(ExitStatus::Success) } /// Displays a hint if an executable matching the package name can be found in a dependency of the package. diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 590958162300..5b6b548f1a02 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -1,13 +1,13 @@ use std::fmt::Write; use std::str::FromStr; -use anyhow::{bail, Result}; +use anyhow::{bail, Context as _, Result}; use distribution_types::UnresolvedRequirementSpecification; use owo_colors::OwoColorize; use pep440_rs::{VersionSpecifier, VersionSpecifiers}; use pep508_rs::MarkerTree; use pypi_types::{Requirement, RequirementSource}; -use tracing::trace; +use tracing::{debug, trace}; use uv_cache::{Cache, Refresh}; use uv_cache_info::Timestamp; use uv_client::{BaseClientBuilder, Connectivity}; @@ -26,7 +26,7 @@ use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; use crate::commands::project::{ resolve_environment, resolve_names, sync_environment, update_environment, }; -use crate::commands::tool::common::remove_entrypoints; +use crate::commands::tool::common::{remove_entrypoints, warn_out_of_path}; use crate::commands::tool::Target; use crate::commands::{reporters::PythonDownloadReporter, tool::common::install_executables}; use crate::commands::{ExitStatus, SharedState}; @@ -39,6 +39,7 @@ pub(crate) async fn install( editable: bool, from: Option, with: &[RequirementsSource], + extra_entrypoints_packages: &[String], python: Option, force: bool, options: ResolverInstallerOptions, @@ -411,14 +412,57 @@ pub(crate) async fn install( .await? }; + // TODO(lucab): we support performing multiple installs in the same environment. + // This force-installs, maybe it can be modeled in a more graceful way instead? + let force_install = force || invalid_tool_receipt || !extra_entrypoints_packages.is_empty(); + + // Find a suitable path to install into. + let executable_directory = uv_tool::find_executable_directory()?; + fs_err::create_dir_all(&executable_directory) + .context("Failed to create executable directory")?; + + // Install additional entrypoints from dependencies, + // if any was explicitly requested. + let mut deps_entrypoints = vec![]; + for entry in extra_entrypoints_packages { + let pkg = PackageName::from_str(entry)?; + debug!( + "Installing entrypoints for {} as part of tool {}", + pkg, from.name + ); + let tool = install_executables( + &environment, + &from.name, + &pkg, + &installed_tools, + &deps_entrypoints, + &executable_directory, + options.clone(), + force_install, + python.clone(), + requirements.clone(), + printer, + )?; + deps_entrypoints.append(&mut tool.into_entrypoints()); + } + + // Install entrypoints from the target package. + debug!("Installing entrypoints tool {}", from.name); install_executables( &environment, &from.name, + &from.name, &installed_tools, + &deps_entrypoints, + &executable_directory, options, - force || invalid_tool_receipt, + force_install, python, requirements, printer, - ) + )?; + + warn_out_of_path(&executable_directory); + + Ok(ExitStatus::Success) } diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 7f4ba71729c2..d5afd78af63d 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -1,6 +1,6 @@ use std::{collections::BTreeSet, fmt::Write}; -use anyhow::Result; +use anyhow::{Context as _, Result}; use owo_colors::OwoColorize; use tracing::debug; @@ -20,6 +20,8 @@ use crate::commands::{tool::common::install_executables, ExitStatus, SharedState use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; +use super::common::warn_out_of_path; + /// Upgrade a tool. pub(crate) async fn upgrade( name: Vec, @@ -196,16 +198,26 @@ async fn upgrade_tool( // existing executables. remove_entrypoints(&existing_tool_receipt); + // Find a suitable path to install into. + let executable_directory = uv_tool::find_executable_directory()?; + fs_err::create_dir_all(&executable_directory) + .context("Failed to create executable directory")?; + install_executables( &environment, name, + name, installed_tools, + &[], + &executable_directory, ToolOptions::from(options), true, existing_tool_receipt.python().to_owned(), requirements.to_vec(), printer, )?; + + warn_out_of_path(&executable_directory); } Ok(changelog) diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 56e74e3bef03..7d608086de8a 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -877,8 +877,14 @@ async fn run(cli: Cli) -> Result { .combine(Refresh::from(args.settings.upgrade.clone())), ); - let requirements = args + // Synthesize extra dependencies by merging all additionally + // specified requirements. + let extra_deps = args .with + .into_iter() + .chain(args.extra_entrypoints_packages.iter().cloned()); + + let requirements = extra_deps .into_iter() .map(RequirementsSource::from_package) .chain( @@ -893,6 +899,7 @@ async fn run(cli: Cli) -> Result { args.editable, args.from, &requirements, + &args.extra_entrypoints_packages, args.python, args.force, args.options, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 641e780a7f5e..83817928757f 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -369,6 +369,7 @@ pub(crate) struct ToolInstallSettings { pub(crate) from: Option, pub(crate) with: Vec, pub(crate) with_requirements: Vec, + pub(crate) extra_entrypoints_packages: Vec, pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) options: ResolverInstallerOptions, @@ -387,6 +388,7 @@ impl ToolInstallSettings { from, with, with_requirements, + i_want_ponies, installer, force, build, @@ -411,6 +413,7 @@ impl ToolInstallSettings { .into_iter() .filter_map(Maybe::into_option) .collect(), + extra_entrypoints_packages: i_want_ponies, python, force, editable, diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index d045e65168e7..ae1dc8d691fa 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -3007,3 +3007,82 @@ fn tool_install_at_latest_upgrade() { "###); }); } + +/// Test installing a tool together with some additional entrypoints +/// from other packages. +#[test] +fn tool_install_additional_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"); + + uv_snapshot!(context.filters(), context.tool_install() + .arg("--i-want-ponies") + .arg("ansible-core") + .arg("--i-want-ponies") + .arg("ansible-lint") + .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-compat==4.1.11 + + ansible-core==2.16.4 + + ansible-lint==24.2.1 + + attrs==23.2.0 + + black==24.3.0 + + bracex==2.4 + + cffi==1.16.0 + + click==8.1.7 + + cryptography==42.0.5 + + filelock==3.13.1 + + jinja2==3.1.3 + + jsonschema==4.21.1 + + jsonschema-specifications==2023.12.[X] + + markdown-it-py==3.0.0 + + markupsafe==2.1.5 + + mdurl==0.1.2 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + + pycparser==2.21 + + pygments==2.17.2 + + pyyaml==6.0.1 + + referencing==0.34.0 + + resolvelib==1.0.1 + + rich==13.7.1 + + rpds-py==0.18.0 + + ruamel-yaml==0.18.6 + + ruamel-yaml-clib==0.2.8 + + subprocess-tee==0.4.1 + + wcmatch==8.5.1 + + yamllint==1.35.1 + Installed 11 executables: ansible, ansible-config, ansible-connection, ansible-console, ansible-doc, ansible-galaxy, ansible-inventory, ansible-playbook, ansible-pull, ansible-test, ansible-vault + Installed 1 executable: ansible-lint + Installed 1 executable: ansible-community + "###); + + 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 24 executables: ansible, ansible, ansible-community, ansible-config, ansible-config, ansible-connection, ansible-connection, ansible-console, ansible-console, ansible-doc, ansible-doc, ansible-galaxy, ansible-galaxy, ansible-inventory, ansible-inventory, ansible-lint, ansible-playbook, ansible-playbook, ansible-pull, ansible-pull, ansible-test, ansible-test, ansible-vault, ansible-vault + "###); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a7ee29fd4982..5d79aabf9764 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2687,6 +2687,8 @@ uv tool install [OPTIONS]
--help, -h

Display the concise help for this command

+
--i-want-ponies i-want-ponies

Additionally include entrypoints from the following packages

+
--index-strategy index-strategy

The strategy to use when resolving against multiple index URLs.

By default, uv will stop at the first index on which a given package is available, and limit resolutions to those present on that first index (first-match). This prevents "dependency confusion" attacks, whereby an attack can upload a malicious package under the same name to a secondary.