diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index f9582dd5e363..c24a35022748 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -1,14 +1,16 @@ use std::borrow::Cow; use std::ffi::OsString; +use std::fmt::Write; use std::path::PathBuf; use std::str::FromStr; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use itertools::Itertools; +use owo_colors::OwoColorize; 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 uv_cache::Cache; use uv_cli::ExternalCommand; @@ -20,7 +22,7 @@ use uv_python::{ EnvironmentPreference, PythonEnvironment, PythonFetch, PythonInstallation, PythonPreference, PythonRequest, }; -use uv_tool::InstalledTools; +use uv_tool::{entrypoint_paths, InstalledTools}; use uv_warnings::warn_user_once; 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."); } + let has_from = from.is_some(); + let (target, args) = command.split(); let Some(target) = target else { return Err(anyhow::anyhow!("No tool command provided")); @@ -79,7 +83,6 @@ pub(crate) async fn run( printer, ) .await?; - // TODO(zanieb): Determine the command via the package entry points let command = target; @@ -118,9 +121,52 @@ pub(crate) async fn run( command.to_string_lossy(), args.iter().map(|arg| arg.to_string_lossy()).join(" ") ); - let mut handle = process - .spawn() - .with_context(|| format!("Failed to spawn: `{}`", command.to_string_lossy()))?; + let mut handle = match process.spawn() { + Ok(handle) => Ok(handle), + 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} "); + 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")?; // 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> { + 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. /// /// If the target tool is already installed in a compatible environment, returns that diff --git a/crates/uv/tests/tool_run.rs b/crates/uv/tests/tool_run.rs index ccce00dcf961..2d232a81e837 100644 --- a/crates/uv/tests/tool_run.rs +++ b/crates/uv/tests/tool_run.rs @@ -69,7 +69,7 @@ fn tool_run_args() { #[test] 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 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 uv_snapshot!(filters, context.tool_run() - .arg("--from") - .arg("pytest") - .arg("pytest@8.0.0") - .arg("--version") - .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .arg("--from") + .arg("pytest") + .arg("pytest@8.0.0") + .arg("--version") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" success: false - exit_code: 2 + exit_code: 1 ----- stdout ----- + The executable pytest@8.0.0 was not found. + However, the following executables are available: + - py.test + - pytest ----- stderr ----- warning: `uv tool run` is experimental and may change without warning. @@ -158,8 +162,6 @@ fn tool_run_at_version() { + packaging==24.0 + pluggy==1.4.0 + 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] fn tool_run_from_install() { let context = TestContext::new("3.12");