diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 2bd056e1c32c..ab3ee7f98cd3 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -15,6 +15,7 @@ use uv_cache_info::Timestamp; use uv_cli::ExternalCommand; use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::{Concurrency, PreviewMode, TrustedHost}; +use uv_distribution_types::InstalledDist; use uv_distribution_types::{Name, UnresolvedRequirement, UnresolvedRequirementSpecification}; use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::PackageName; @@ -30,7 +31,7 @@ use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_settings::PythonInstallMirrors; use uv_static::EnvVars; use uv_tool::{entrypoint_paths, InstalledTools}; -use uv_warnings::warn_user; +use uv_warnings::warn_user_once; use crate::commands::pip::loggers::{ DefaultInstallLogger, DefaultResolveLogger, SummaryInstallLogger, SummaryResolveLogger, @@ -135,6 +136,7 @@ pub(crate) async fn run( ) .await; + let explicit_from = from.is_some(); let (from, environment) = match result { Ok(resolution) => resolution, Err(ProjectError::Operation(err)) => { @@ -154,6 +156,29 @@ pub(crate) async fn run( // TODO(zanieb): Determine the executable command via the package entry points let executable = from.executable(); + let site_packages = SitePackages::from_environment(&environment)?; + + // Check if the provided command is not part of the executables for the `from` package, + // and if it's provided by another package in the environment. + let providers = + ExecutableProviderPackages::new(executable, &from, &site_packages, invocation_source); + + if providers.not_from_any() { + if !explicit_from { + // If the user didn't use `--from` and the command isn't in the environment, we're now + // just invoking an arbitrary executable on the `PATH` and should exit instead. + writeln!(printer.stderr(), "{providers}")?; + return Ok(ExitStatus::Failure); + } + // In the case where `--from` is used, we'll warn on failure if the command is not found + // TODO(zanieb): Consider if we should require `--with` instead of `--from` in this case? + // It'd be a breaking change but would make `uvx` invocations safer. + } else if providers.not_from_expected() { + // However, if the user used `--from`, we shouldn't fail because they requested that the + // package and executable be different. We'll warn if the executable comes from another + // package though, because that could be confusing + warn_user_once!("{providers}"); + } // Construct the command let mut process = Command::new(executable); @@ -172,7 +197,6 @@ pub(crate) async fn run( // Spawn and wait for completion // Standard input, output, and error streams are all inherited - // TODO(zanieb): Throw a nicer error message if the command is not found let space = if args.is_empty() { "" } else { " " }; debug!( "Running `{}{space}{}`", @@ -180,35 +204,15 @@ pub(crate) async fn run( args.iter().map(|arg| arg.to_string_lossy()).join(" ") ); - let site_packages = SitePackages::from_environment(&environment)?; - - // We check if the provided command is not part of the executables for the `from` package. - // If the command is found in other packages, we warn the user about the correct package to use. - match &from { - ToolRequirement::Python => {} - ToolRequirement::Package { - requirement: from, .. - } => { - warn_executable_not_provided_by_package( - executable, - &from.name, - &site_packages, - invocation_source, - ); - } - } - let handle = match process.spawn() { Ok(handle) => Ok(handle), Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - if let Some(exit_status) = hint_on_not_found( - executable, - &from, - &site_packages, - invocation_source, - printer, - )? { - return Ok(exit_status); + if providers.not_from_any() && explicit_from { + // We deferred this warning earlier, because `--from` was used and the command + // could have come from the `PATH`. Display a more helpful message instead of the + // OS error. + writeln!(printer.stderr(), "{providers}")?; + return Ok(ExitStatus::Failure); } Err(err) } @@ -219,67 +223,6 @@ pub(crate) async fn run( run_to_completion(handle).await } -/// Show a hint when a command fails due to a missing executable. -/// -/// Returns an exit status if the caller should exit after hinting. -fn hint_on_not_found( - executable: &str, - from: &ToolRequirement, - site_packages: &SitePackages, - invocation_source: ToolRunCommand, - printer: Printer, -) -> anyhow::Result> { - let from = match from { - ToolRequirement::Python => return Ok(None), - ToolRequirement::Package { - requirement: from, .. - } => from, - }; - match get_entrypoints(&from.name, site_packages) { - Ok(entrypoints) => { - writeln!( - printer.stdout(), - "The executable `{}` was not found.", - executable.cyan(), - )?; - if entrypoints.is_empty() { - warn_user!( - "Package `{}` does not provide any executables.", - from.name.red() - ); - } else { - warn_user!( - "An executable named `{}` is not provided by package `{}`.", - executable.cyan(), - from.name.red() - ); - writeln!( - printer.stdout(), - "The following executables are provided by `{}`:", - from.name.green() - )?; - for (name, _) in entrypoints { - writeln!(printer.stdout(), "- {}", name.cyan())?; - } - let suggested_command = format!( - "{} --from {} ", - invocation_source, from.name - ); - writeln!( - printer.stdout(), - "Consider using `{}` instead.", - suggested_command.green() - )?; - } - Ok(Some(ExitStatus::Failure)) - } - Err(err) => { - warn!("Failed to get entrypoints for `{from}`: {err}"); - Ok(None) - } - } -} - /// Return the entry points for the specified package. fn get_entrypoints( from: &PackageName, @@ -362,52 +305,163 @@ async fn show_help( Ok(()) } -/// Display a warning if an executable is not provided by package. -/// -/// If found in a dependency of the requested package instead of the requested package itself, we will hint to use that instead. -fn warn_executable_not_provided_by_package( - executable: &str, - from_package: &PackageName, - site_packages: &SitePackages, +struct ExecutableProviderPackages<'a> { + /// The requested executable for the command + executable: &'a str, + /// The package from which the executable is expected to come from + from: &'a ToolRequirement, + /// The packages in the Pythonenvironment the command will run it + site_packages: &'a SitePackages, + /// The matching packages + packages: Vec, invocation_source: ToolRunCommand, -) { - let packages = matching_packages(executable, site_packages); - if !packages - .iter() - .any(|package| package.name() == from_package) - { +} + +impl<'a> ExecutableProviderPackages<'a> { + fn new( + executable: &'a str, + from: &'a ToolRequirement, + site_packages: &'a SitePackages, + invocation_source: ToolRunCommand, + ) -> Self { + let packages = matching_packages(executable, site_packages); + ExecutableProviderPackages { + executable, + from, + site_packages, + packages, + invocation_source, + } + } + + fn not_from_expected(&self) -> bool { + let from = match self.from { + // Nothing to do for Python + ToolRequirement::Python => return false, + ToolRequirement::Package { requirement, .. } => requirement, + }; + !self + .packages + .iter() + .any(|package| package.name() == &from.name) + } + + fn not_from_any(&self) -> bool { + match self.from { + // Nothing to do for Python + ToolRequirement::Python => return false, + ToolRequirement::Package { .. } => {} + }; + self.packages.is_empty() + } +} + +impl std::fmt::Display for ExecutableProviderPackages<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + executable, + from, + site_packages, + packages, + invocation_source, + } = self; + + let from = match from { + // Nothing to do for Python + ToolRequirement::Python => { + debug_assert!(false, "Attempted to display providers for a `python` invocation which is always available"); + return Ok(()); + } + ToolRequirement::Package { requirement, .. } => requirement, + }; + match packages.as_slice() { - [] => {} + [] => { + let entrypoints = match get_entrypoints(&from.name, site_packages) { + Ok(entrypoints) => entrypoints, + Err(err) => { + warn!("Failed to get entrypoints for `{from}`: {err}"); + return Ok(()); + } + }; + if entrypoints.is_empty() { + write!( + f, + "Package `{}` does not provide any executables.", + from.name.red() + )?; + return Ok(()); + } + writeln!( + f, + "An executable named `{}` is not provided by package `{}`.", + executable.cyan(), + from.name.cyan(), + )?; + writeln!(f, "The following executables are available:")?; + for (name, _) in &entrypoints { + writeln!(f, "- {}", name.cyan())?; + } + let name = match entrypoints.as_slice() { + [entrypoint] => entrypoint.0.as_str(), + _ => "", + }; + // If the user didn't use `--from`, suggest it + if *executable == from.name.as_str() { + let suggested_command = + format!("{} --from {} {name}", invocation_source, from.name); + writeln!(f, "\nUse `{}` instead.", suggested_command.green().bold())?; + } + } + [package] if package.name() == &from.name => { + write!( + f, + "An executable named `{}` is provided by package `{}`", + executable.cyan(), + from.name.cyan(), + )?; + } [package] => { let suggested_command = format!( "{invocation_source} --from {} {}", package.name(), executable ); - warn_user!( - "An executable named `{}` is not provided by package `{}` but is available via the dependency `{}`. Consider using `{}` instead.", - executable.cyan(), - from_package.cyan(), - package.name().cyan(), - suggested_command.green() - ); + write!(f, + "An executable named `{}` is not provided by package `{}` but is available via the dependency `{}`. Consider using `{}` instead.", + executable.cyan(), + from.name.cyan(), + package.name().cyan(), + suggested_command.green() + )?; } packages => { - let suggested_command = format!("{invocation_source} --from PKG {executable}"); let provided_by = packages .iter() .map(uv_distribution_types::Name::name) .map(|name| format!("- {}", name.cyan())) .join("\n"); - warn_user!( - "An executable named `{}` is not provided by package `{}` but is available via the following dependencies:\n- {}\nConsider using `{}` instead.", - executable.cyan(), - from_package.cyan(), - provided_by, - suggested_command.green(), - ); + if self.not_from_expected() { + let suggested_command = format!("{invocation_source} --from PKG {executable}"); + write!(f, + "An executable named `{}` is not provided by package `{}` but is available via the following dependencies:\n- {}\nConsider using `{}` instead.", + executable.cyan(), + from.name.cyan(), + provided_by, + suggested_command.green(), + )?; + } else { + write!(f, + "An executable named `{}` is provided by package `{}` but is also available via the following dependencies:\n- {}\nUnexpected behavior may occur.", + executable.cyan(), + from.name.cyan(), + provided_by, + )?; + } } } + + Ok(()) } } diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs index ede1f187148e..38b227f03a46 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -138,15 +138,10 @@ fn tool_run_at_version() { .arg("pytest@8.0.0") .arg("--version") .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) - .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###" + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r" success: false exit_code: 1 ----- stdout ----- - The executable `pytest@8.0.0` was not found. - The following executables are provided by `pytest`: - - py.test - - pytest - Consider using `uv tool run --from pytest ` instead. ----- stderr ----- Resolved 4 packages in [TIME] @@ -156,8 +151,11 @@ fn tool_run_at_version() { + packaging==24.0 + pluggy==1.4.0 + pytest==8.1.1 - warning: An executable named `pytest@8.0.0` is not provided by package `pytest`. - "###); + An executable named `pytest@8.0.0` is not provided by package `pytest`. + The following executables are available: + - py.test + - pytest + "); } #[test] @@ -200,15 +198,10 @@ fn tool_run_suggest_valid_commands() { .arg("black") .arg("orange") .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) - .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###" + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r" success: false exit_code: 1 ----- stdout ----- - The executable `orange` was not found. - The following executables are provided by `black`: - - black - - blackd - Consider using `uv tool run --from black ` instead. ----- stderr ----- Resolved 6 packages in [TIME] @@ -220,17 +213,19 @@ fn tool_run_suggest_valid_commands() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 - warning: An executable named `orange` is not provided by package `black`. - "###); + An executable named `orange` is not provided by package `black`. + The following executables are available: + - black + - blackd + "); uv_snapshot!(context.filters(), context.tool_run() .arg("fastapi-cli") .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) - .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###" + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r" success: false exit_code: 1 ----- stdout ----- - The executable `fastapi-cli` was not found. ----- stderr ----- Resolved 3 packages in [TIME] @@ -239,8 +234,8 @@ fn tool_run_suggest_valid_commands() { + fastapi-cli==0.0.1 + importlib-metadata==1.7.0 + zipp==3.18.1 - warning: Package `fastapi-cli` does not provide any executables. - "###); + Package `fastapi-cli` does not provide any executables. + "); } #[test] @@ -262,7 +257,7 @@ fn tool_run_warn_executable_not_in_from() { .arg("fastapi") .arg("fastapi") .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) - .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###" + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r" success: false exit_code: 2 ----- stdout ----- @@ -306,7 +301,7 @@ fn tool_run_warn_executable_not_in_from() { + watchfiles==0.21.0 + websockets==12.0 warning: An executable named `fastapi` is not provided by package `fastapi` but is available via the dependency `fastapi-cli`. Consider using `uv tool run --from fastapi-cli fastapi` instead. - "###); + "); } #[test] @@ -1292,11 +1287,10 @@ fn warn_no_executables_found() { uv_snapshot!(context.filters(), context.tool_run() .arg("requests") .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) - .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###" + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r" success: false exit_code: 1 ----- stdout ----- - The executable `requests` was not found. ----- stderr ----- Resolved 5 packages in [TIME] @@ -1307,8 +1301,8 @@ fn warn_no_executables_found() { + idna==3.6 + requests==2.31.0 + urllib3==2.2.1 - warning: Package `requests` does not provide any executables. - "###); + Package `requests` does not provide any executables. + "); } /// Warn when a user passes `--upgrade` to `uv tool run`. @@ -1857,19 +1851,19 @@ fn tool_run_verbatim_name() { .arg("change-wheel-version") .arg("--help") .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) - .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###" + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r" success: false exit_code: 1 ----- stdout ----- - The executable `change-wheel-version` was not found. - The following executables are provided by `change-wheel-version`: - - change_wheel_version - Consider using `uv tool run --from change-wheel-version ` instead. ----- stderr ----- Resolved [N] packages in [TIME] - warning: An executable named `change-wheel-version` is not provided by package `change-wheel-version`. - "###); + An executable named `change-wheel-version` is not provided by package `change-wheel-version`. + The following executables are available: + - change_wheel_version + + Use `uv tool run --from change-wheel-version change_wheel_version` instead. + "); uv_snapshot!(context.filters(), context.tool_run() .arg("--from")