Skip to content

Commit

Permalink
uv tool install hint the correct when the executable is available (#…
Browse files Browse the repository at this point in the history
…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 <[email protected]>
  • Loading branch information
blueraft and zanieb authored Jul 15, 2024
1 parent 09ae7a9 commit 493a2bf
Show file tree
Hide file tree
Showing 3 changed files with 156 additions and 8 deletions.
33 changes: 32 additions & 1 deletion crates/uv/src/commands/tool/common.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<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)
}
64 changes: 59 additions & 5 deletions crates/uv/src/commands/tool/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -296,10 +300,17 @@ pub(crate) async fn install(
.collect::<BTreeSet<_>>();

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
Expand Down Expand Up @@ -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(())
}
67 changes: 65 additions & 2 deletions crates/uv/tests/tool_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -911,16 +974,16 @@ 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.
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
error: No executables found for `iniconfig`
"###);
}

Expand Down

0 comments on commit 493a2bf

Please sign in to comment.