uv tool install hint the correct when the executable is available (#5019)

## Summary

Resolves #5018.

## Test Plan

`cargo test`

<img width="704" alt="Screenshot 2024-07-12 at 22 16 53"
src="https://github.com/user-attachments/assets/d2d4d85b-d6c3-4b47-8f1a-bb07112d5931">

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
Ahmed Ilyas 2024-07-15 18:54:39 +02:00 committed by GitHub
parent 09ae7a93d1
commit 493a2bfe63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 156 additions and 8 deletions

View file

@ -1,9 +1,12 @@
use distribution_types::{InstalledDist, Name};
use pypi_types::Requirement; use pypi_types::Requirement;
use uv_cache::Cache; use uv_cache::Cache;
use uv_client::Connectivity; use uv_client::Connectivity;
use uv_configuration::{Concurrency, PreviewMode}; use uv_configuration::{Concurrency, PreviewMode};
use uv_python::Interpreter; use uv_installer::SitePackages;
use uv_python::{Interpreter, PythonEnvironment};
use uv_requirements::RequirementsSpecification; use uv_requirements::RequirementsSpecification;
use uv_tool::entrypoint_paths;
use crate::commands::{project, SharedState}; use crate::commands::{project, SharedState};
use crate::printer::Printer; use crate::printer::Printer;
@ -46,3 +49,31 @@ pub(super) async fn resolve_requirements(
) )
.await .await
} }
/// Return all packages which contain an executable with the given name.
pub(super) fn matching_packages(
name: &str,
environment: &PythonEnvironment,
) -> anyhow::Result<Vec<InstalledDist>> {
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)
}

View file

@ -6,7 +6,7 @@ use std::str::FromStr;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use itertools::Itertools; use itertools::Itertools;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use tracing::debug; use tracing::{debug, warn};
use distribution_types::Name; use distribution_types::Name;
use pypi_types::Requirement; use pypi_types::Requirement;
@ -19,16 +19,20 @@ use uv_fs::Simplified;
use uv_installer::SitePackages; use uv_installer::SitePackages;
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_python::{ use uv_python::{
EnvironmentPreference, PythonFetch, PythonInstallation, PythonPreference, PythonRequest, EnvironmentPreference, PythonEnvironment, PythonFetch, PythonInstallation, PythonPreference,
PythonRequest,
}; };
use uv_requirements::RequirementsSpecification; use uv_requirements::RequirementsSpecification;
use uv_shell::Shell; use uv_shell::Shell;
use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint}; use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint};
use uv_warnings::{warn_user, warn_user_once}; 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::reporters::PythonDownloadReporter;
use crate::commands::tool::common::resolve_requirements; 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::commands::{ExitStatus, SharedState};
use crate::printer::Printer; use crate::printer::Printer;
use crate::settings::ResolverInstallerSettings; use crate::settings::ResolverInstallerSettings;
@ -296,10 +300,17 @@ pub(crate) async fn install(
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
if target_entry_points.is_empty() { 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 // Clean up the environment we just created
installed_tools.remove_environment(&from.name)?; installed_tools.remove_environment(&from.name)?;
return Ok(ExitStatus::Failure);
bail!("No executables found for `{}`", from.name);
} }
// Check if they exist, before installing // Check if they exist, before installing
@ -407,3 +418,46 @@ pub(crate) async fn install(
Ok(ExitStatus::Success) 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(())
}

View file

@ -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 installing a tool at a version
#[test] #[test]
fn tool_install_version() { fn tool_install_version() {
@ -911,8 +974,9 @@ fn tool_install_no_entrypoints() {
.env("XDG_BIN_HOME", bin_dir.as_os_str()) .env("XDG_BIN_HOME", bin_dir.as_os_str())
.env("PATH", bin_dir.as_os_str()), @r###" .env("PATH", bin_dir.as_os_str()), @r###"
success: false success: false
exit_code: 2 exit_code: 1
----- stdout ----- ----- stdout -----
No executables are provided by package `iniconfig`.
----- stderr ----- ----- stderr -----
warning: `uv tool install` is experimental and may change without warning. 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] Prepared 1 package in [TIME]
Installed 1 package in [TIME] Installed 1 package in [TIME]
+ iniconfig==2.0.0 + iniconfig==2.0.0
error: No executables found for `iniconfig`
"###); "###);
} }