From 493a2bfe63d0ac2d8487d9fe3d3832505d8ffe22 Mon Sep 17 00:00:00 2001 From: Ahmed Ilyas Date: Mon, 15 Jul 2024 18:54:39 +0200 Subject: [PATCH] `uv tool install` hint the correct when the executable is available (#5019) ## Summary Resolves #5018. ## Test Plan `cargo test` Screenshot 2024-07-12 at 22 16 53 --------- Co-authored-by: Zanie Blue --- crates/uv/src/commands/tool/common.rs | 33 ++++++++++++- crates/uv/src/commands/tool/install.rs | 64 ++++++++++++++++++++++-- crates/uv/tests/tool_install.rs | 67 +++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 8 deletions(-) diff --git a/crates/uv/src/commands/tool/common.rs b/crates/uv/src/commands/tool/common.rs index f6558e011..3e99f3271 100644 --- a/crates/uv/src/commands/tool/common.rs +++ b/crates/uv/src/commands/tool/common.rs @@ -1,9 +1,12 @@ +use distribution_types::{InstalledDist, Name}; use pypi_types::Requirement; use uv_cache::Cache; use uv_client::Connectivity; use uv_configuration::{Concurrency, PreviewMode}; -use uv_python::Interpreter; +use uv_installer::SitePackages; +use uv_python::{Interpreter, PythonEnvironment}; use uv_requirements::RequirementsSpecification; +use uv_tool::entrypoint_paths; use crate::commands::{project, SharedState}; use crate::printer::Printer; @@ -46,3 +49,31 @@ pub(super) async fn resolve_requirements( ) .await } + +/// Return all packages which contain an executable with the given name. +pub(super) fn matching_packages( + name: &str, + environment: &PythonEnvironment, +) -> anyhow::Result> { + let site_packages = SitePackages::from_environment(environment)?; + let entrypoints = site_packages + .iter() + .filter_map(|package| { + entrypoint_paths(environment, package.name(), package.version()) + .ok() + .and_then(|entrypoints| { + entrypoints + .iter() + .any(|entrypoint| { + entrypoint + .0 + .strip_suffix(std::env::consts::EXE_SUFFIX) + .is_some_and(|stripped| stripped == name) + }) + .then(|| package.clone()) + }) + }) + .collect(); + + Ok(entrypoints) +} diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 0e944ffbf..347d78e56 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -6,7 +6,7 @@ use std::str::FromStr; use anyhow::{bail, Context, Result}; use itertools::Itertools; use owo_colors::OwoColorize; -use tracing::debug; +use tracing::{debug, warn}; use distribution_types::Name; use pypi_types::Requirement; @@ -19,16 +19,20 @@ use uv_fs::Simplified; use uv_installer::SitePackages; use uv_normalize::PackageName; use uv_python::{ - EnvironmentPreference, PythonFetch, PythonInstallation, PythonPreference, PythonRequest, + EnvironmentPreference, PythonEnvironment, PythonFetch, PythonInstallation, PythonPreference, + PythonRequest, }; use uv_requirements::RequirementsSpecification; use uv_shell::Shell; use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint}; use uv_warnings::{warn_user, warn_user_once}; -use crate::commands::project::{resolve_environment, sync_environment, update_environment}; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::tool::common::resolve_requirements; +use crate::commands::{ + project::{resolve_environment, sync_environment, update_environment}, + tool::common::matching_packages, +}; use crate::commands::{ExitStatus, SharedState}; use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; @@ -296,10 +300,17 @@ pub(crate) async fn install( .collect::>(); if target_entry_points.is_empty() { + writeln!( + printer.stdout(), + "No executables are provided by package `{}`.", + from.name.red() + )?; + + hint_executable_from_dependency(&from, &environment, printer)?; + // Clean up the environment we just created installed_tools.remove_environment(&from.name)?; - - bail!("No executables found for `{}`", from.name); + return Ok(ExitStatus::Failure); } // Check if they exist, before installing @@ -407,3 +418,46 @@ pub(crate) async fn install( Ok(ExitStatus::Success) } + +/// Displays a hint if an executable matching the package name can be found in a dependency of the package. +fn hint_executable_from_dependency( + from: &Requirement, + environment: &PythonEnvironment, + printer: Printer, +) -> Result<()> { + match matching_packages(from.name.as_ref(), environment) { + Ok(packages) => match packages.as_slice() { + [] => {} + [package] => { + let command = format!("uv tool install {}", package.name()); + writeln!( + printer.stdout(), + "However, an executable with the name `{}` is available via dependency `{}`.\nDid you mean `{}`?", + from.name.green(), + package.name().green(), + command.bold(), + )?; + } + packages => { + writeln!( + printer.stdout(), + "However, an executable with the name `{}` is available via the following dependencies::", + from.name.green(), + )?; + + for package in packages { + writeln!(printer.stdout(), "- {}", package.name().cyan())?; + } + writeln!( + printer.stdout(), + "Did you mean to install one of them instead?" + )?; + } + }, + Err(err) => { + warn!("Failed to determine executables for packages: {err}"); + } + } + + Ok(()) +} diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index 72addee7f..a8c41516a 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -170,6 +170,69 @@ fn tool_install() { }); } +#[test] +fn tool_install_suggest_other_packages_with_executable() { + let context = TestContext::new("3.12").with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + let mut filters = context.filters(); + filters.push(("\\+ uvloop(.+)\n ", "")); + + uv_snapshot!(filters, context.tool_install_without_exclude_newer() + .arg("fastapi==0.111.0") + .env("UV_EXCLUDE_NEWER", "2024-05-04T00:00:00Z") // TODO: Remove this once EXCLUDE_NEWER is bumped past 2024-05-04 + // (FastAPI 0.111 is only available from this date onwards) + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: false + exit_code: 1 + ----- stdout ----- + No executables are provided by package `fastapi`. + However, an executable with the name `fastapi` is available via dependency `fastapi-cli`. + Did you mean `uv tool install fastapi-cli`? + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + Resolved 35 packages in [TIME] + Prepared 35 packages in [TIME] + Installed 35 packages in [TIME] + + annotated-types==0.6.0 + + anyio==4.3.0 + + certifi==2024.2.2 + + click==8.1.7 + + dnspython==2.6.1 + + email-validator==2.1.1 + + fastapi==0.111.0 + + fastapi-cli==0.0.2 + + h11==0.14.0 + + httpcore==1.0.5 + + httptools==0.6.1 + + httpx==0.27.0 + + idna==3.7 + + jinja2==3.1.3 + + markdown-it-py==3.0.0 + + markupsafe==2.1.5 + + mdurl==0.1.2 + + orjson==3.10.3 + + pydantic==2.7.1 + + pydantic-core==2.18.2 + + pygments==2.17.2 + + python-dotenv==1.0.1 + + python-multipart==0.0.9 + + pyyaml==6.0.1 + + rich==13.7.1 + + shellingham==1.5.4 + + sniffio==1.3.1 + + starlette==0.37.2 + + typer==0.12.3 + + typing-extensions==4.11.0 + + ujson==5.9.0 + + uvicorn==0.29.0 + + watchfiles==0.21.0 + + websockets==12.0 + "###); +} + /// Test installing a tool at a version #[test] fn tool_install_version() { @@ -911,8 +974,9 @@ fn tool_install_no_entrypoints() { .env("XDG_BIN_HOME", bin_dir.as_os_str()) .env("PATH", bin_dir.as_os_str()), @r###" success: false - exit_code: 2 + exit_code: 1 ----- stdout ----- + No executables are provided by package `iniconfig`. ----- stderr ----- warning: `uv tool install` is experimental and may change without warning. @@ -920,7 +984,6 @@ fn tool_install_no_entrypoints() { Prepared 1 package in [TIME] Installed 1 package in [TIME] + iniconfig==2.0.0 - error: No executables found for `iniconfig` "###); }