uv tool run suggest valid commands when command is not found (#4997)

## Summary

Resolves #4979.


## Test Plan

`cargo test`

<img width="619" alt="Screenshot 2024-07-11 at 22 45 36"
src="https://github.com/user-attachments/assets/62526010-9123-43f5-9f8d-1f9e89f6be59">

<img width="636" alt="Screenshot 2024-07-11 at 22 45 23"
src="https://github.com/user-attachments/assets/a348cd73-f891-40b1-8934-afbd1aa19326">
This commit is contained in:
Ahmed Ilyas 2024-07-12 04:26:15 +02:00 committed by GitHub
parent 6949796110
commit 23c6cd774b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 136 additions and 18 deletions

View file

@ -1,14 +1,16 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::ffi::OsString; use std::ffi::OsString;
use std::fmt::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use anyhow::{Context, Result}; use anyhow::{bail, Context, Result};
use itertools::Itertools; use itertools::Itertools;
use owo_colors::OwoColorize;
use tokio::process::Command; use tokio::process::Command;
use tracing::debug; use tracing::{debug, warn};
use distribution_types::UnresolvedRequirementSpecification; use distribution_types::{Name, UnresolvedRequirementSpecification};
use pep440_rs::Version; use pep440_rs::Version;
use uv_cache::Cache; use uv_cache::Cache;
use uv_cli::ExternalCommand; use uv_cli::ExternalCommand;
@ -20,7 +22,7 @@ use uv_python::{
EnvironmentPreference, PythonEnvironment, PythonFetch, PythonInstallation, PythonPreference, EnvironmentPreference, PythonEnvironment, PythonFetch, PythonInstallation, PythonPreference,
PythonRequest, PythonRequest,
}; };
use uv_tool::InstalledTools; use uv_tool::{entrypoint_paths, InstalledTools};
use uv_warnings::warn_user_once; use uv_warnings::warn_user_once;
use crate::commands::project::environment::CachedEnvironment; use crate::commands::project::environment::CachedEnvironment;
@ -51,6 +53,8 @@ pub(crate) async fn run(
warn_user_once!("`uv tool run` is experimental and may change without warning."); warn_user_once!("`uv tool run` is experimental and may change without warning.");
} }
let has_from = from.is_some();
let (target, args) = command.split(); let (target, args) = command.split();
let Some(target) = target else { let Some(target) = target else {
return Err(anyhow::anyhow!("No tool command provided")); return Err(anyhow::anyhow!("No tool command provided"));
@ -79,7 +83,6 @@ pub(crate) async fn run(
printer, printer,
) )
.await?; .await?;
// TODO(zanieb): Determine the command via the package entry points // TODO(zanieb): Determine the command via the package entry points
let command = target; let command = target;
@ -118,9 +121,52 @@ pub(crate) async fn run(
command.to_string_lossy(), command.to_string_lossy(),
args.iter().map(|arg| arg.to_string_lossy()).join(" ") args.iter().map(|arg| arg.to_string_lossy()).join(" ")
); );
let mut handle = process let mut handle = match process.spawn() {
.spawn() Ok(handle) => Ok(handle),
.with_context(|| format!("Failed to spawn: `{}`", command.to_string_lossy()))?; Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
match get_entrypoints(&from, &environment) {
Ok(entrypoints) => {
if entrypoints.is_empty() {
writeln!(
printer.stdout(),
"The executable {} was not found.",
command.to_string_lossy().red(),
)?;
} else {
writeln!(
printer.stdout(),
"The executable {} was not found.",
command.to_string_lossy().red()
)?;
if has_from {
writeln!(
printer.stdout(),
"However, the following executables are available:",
)?;
} else {
let command = format!("uv tool run --from {from} <EXECUTABLE>");
writeln!(
printer.stdout(),
"However, the following executables are available via {}:",
command.green(),
)?;
}
for (name, _) in entrypoints {
writeln!(printer.stdout(), "- {}", name.cyan())?;
}
}
return Ok(ExitStatus::Failure);
}
Err(err) => {
warn!("Failed to get entrypoints for `{from}`: {err}");
}
}
Err(err)
}
Err(err) => Err(err),
}
.with_context(|| format!("Failed to spawn: `{}`", command.to_string_lossy()))?;
let status = handle.wait().await.context("Child process disappeared")?; let status = handle.wait().await.context("Child process disappeared")?;
// Exit based on the result of the command // Exit based on the result of the command
@ -132,6 +178,23 @@ pub(crate) async fn run(
} }
} }
/// Return the entry points for the specified package.
fn get_entrypoints(from: &str, environment: &PythonEnvironment) -> Result<Vec<(String, PathBuf)>> {
let site_packages = SitePackages::from_environment(environment)?;
let package = PackageName::from_str(from)?;
let installed = site_packages.get_packages(&package);
let Some(installed_dist) = installed.first().copied() else {
bail!("Expected at least one requirement")
};
Ok(entrypoint_paths(
environment,
installed_dist.name(),
installed_dist.version(),
)?)
}
/// Get or create a [`PythonEnvironment`] in which to run the specified tools. /// Get or create a [`PythonEnvironment`] in which to run the specified tools.
/// ///
/// If the target tool is already installed in a compatible environment, returns that /// If the target tool is already installed in a compatible environment, returns that

View file

@ -69,7 +69,7 @@ fn tool_run_args() {
#[test] #[test]
fn tool_run_at_version() { fn tool_run_at_version() {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12").with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools"); let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin"); let bin_dir = context.temp_dir.child("bin");
@ -139,15 +139,19 @@ fn tool_run_at_version() {
// When `--from` is used, `@` is not treated as a version request // When `--from` is used, `@` is not treated as a version request
uv_snapshot!(filters, context.tool_run() uv_snapshot!(filters, context.tool_run()
.arg("--from") .arg("--from")
.arg("pytest") .arg("pytest")
.arg("pytest@8.0.0") .arg("pytest@8.0.0")
.arg("--version") .arg("--version")
.env("UV_TOOL_DIR", tool_dir.as_os_str()) .env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###"
success: false success: false
exit_code: 2 exit_code: 1
----- stdout ----- ----- stdout -----
The executable pytest@8.0.0 was not found.
However, the following executables are available:
- py.test
- pytest
----- stderr ----- ----- stderr -----
warning: `uv tool run` is experimental and may change without warning. warning: `uv tool run` is experimental and may change without warning.
@ -158,8 +162,6 @@ fn tool_run_at_version() {
+ packaging==24.0 + packaging==24.0
+ pluggy==1.4.0 + pluggy==1.4.0
+ pytest==8.1.1 + pytest==8.1.1
error: Failed to spawn: `pytest@8.0.0`
Caused by: No such file or directory (os error 2)
"###); "###);
} }
@ -193,6 +195,59 @@ fn tool_run_from_version() {
"###); "###);
} }
#[test]
fn tool_run_suggest_valid_commands() {
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");
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("black")
.arg("orange")
.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 -----
The executable orange was not found.
However, the following executables are available:
- black
- blackd
----- stderr -----
warning: `uv tool run` is experimental and may change without warning.
Resolved 6 packages in [TIME]
Prepared 6 packages in [TIME]
Installed 6 packages in [TIME]
+ black==24.3.0
+ click==8.1.7
+ mypy-extensions==1.0.0
+ packaging==24.0
+ pathspec==0.12.1
+ platformdirs==4.2.0
"###);
uv_snapshot!(context.filters(), context.tool_run()
.arg("fastapi-cli")
.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 -----
The executable fastapi-cli was not found.
----- stderr -----
warning: `uv tool run` is experimental and may change without warning.
Resolved 3 packages in [TIME]
Prepared 3 packages in [TIME]
Installed 3 packages in [TIME]
+ fastapi-cli==0.0.1
+ importlib-metadata==1.7.0
+ zipp==3.18.1
"###);
}
#[test] #[test]
fn tool_run_from_install() { fn tool_run_from_install() {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12");