diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 7d4cbc174..66bcade91 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -19,6 +19,7 @@ use uv_cli::ExternalCommand; use uv_client::BaseClientBuilder; use uv_configuration::Constraints; use uv_configuration::{Concurrency, PreviewMode}; +use uv_distribution_types::InstalledDist; use uv_distribution_types::{ IndexUrl, Name, NameRequirementSpecification, Requirement, RequirementSource, UnresolvedRequirement, UnresolvedRequirementSpecification, @@ -39,6 +40,7 @@ use uv_shell::runnable::WindowsRunnable; use uv_static::EnvVars; use uv_tool::{entrypoint_paths, InstalledTools}; use uv_warnings::warn_user; +use uv_warnings::warn_user_once; use uv_workspace::WorkspaceCache; use crate::commands::pip::loggers::{ @@ -270,6 +272,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)) => { @@ -304,6 +307,38 @@ 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 provider_hints = match &from { + ToolRequirement::Python => None, + ToolRequirement::Package { requirement, .. } => Some(ExecutableProviderHints::new( + executable, + requirement, + &site_packages, + invocation_source, + )), + }; + + if let Some(ref provider_hints) = provider_hints { + if provider_hints.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(), "{provider_hints}")?; + 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 provider_hints.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!("{provider_hints}"); + } + } // Construct the command let mut process = if cfg!(windows) { @@ -327,7 +362,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}{}`", @@ -335,35 +369,17 @@ 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 let Some(ref provider_hints) = provider_hints { + if provider_hints.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(), "{provider_hints}")?; + return Ok(ExitStatus::Failure); + } } Err(err) } @@ -374,67 +390,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, @@ -517,52 +472,149 @@ 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, +/// A set of hints about the packages that provide an executable. +#[derive(Debug)] +struct ExecutableProviderHints<'a> { + /// The requested executable for the command + executable: &'a str, + /// The package from which the executable is expected to come from + from: &'a Requirement, + /// The packages in the [`PythonEnvironment`] the command will run in + site_packages: &'a SitePackages, + /// The packages with matching executable names + packages: Vec, + /// The source of the invocation, for suggestions to the user invocation_source: ToolRunCommand, -) { - let packages = matching_packages(executable, site_packages); - if !packages - .iter() - .any(|package| package.name() == from_package) - { +} + +impl<'a> ExecutableProviderHints<'a> { + fn new( + executable: &'a str, + from: &'a Requirement, + site_packages: &'a SitePackages, + invocation_source: ToolRunCommand, + ) -> Self { + let packages = matching_packages(executable, site_packages); + ExecutableProviderHints { + executable, + from, + site_packages, + packages, + invocation_source, + } + } + + /// If the executable is not provided by the expected package. + fn not_from_expected(&self) -> bool { + !self + .packages + .iter() + .any(|package| package.name() == &self.from.name) + } + + /// If the executable is not provided by any package. + fn not_from_any(&self) -> bool { + self.packages.is_empty() + } +} + +impl std::fmt::Display for ExecutableProviderHints<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + executable, + from, + site_packages, + packages, + invocation_source, + } = self; + 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 59d28f559..037cd41af 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -139,15 +139,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] @@ -157,8 +152,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] @@ -265,15 +263,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] @@ -285,17 +278,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] @@ -304,8 +299,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] @@ -327,7 +322,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 ----- @@ -371,7 +366,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] @@ -1540,11 +1535,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] @@ -1555,8 +1549,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`. @@ -2198,19 +2192,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") @@ -2512,16 +2506,14 @@ fn tool_run_windows_runnable_types() -> anyhow::Result<()> { success: false exit_code: 1 ----- stdout ----- - The executable `does_not_exist` was not found. - The following executables are provided by `foo`: + + ----- stderr ----- + An executable named `does_not_exist` is not provided by package `foo`. + The following executables are available: - custom_pydoc.exe - custom_pydoc.bat - custom_pydoc.cmd - custom_pydoc.ps1 - Consider using `uv tool run --from foo ` instead. - - ----- stderr ----- - warning: An executable named `does_not_exist` is not provided by package `foo`. "###); // Test with explicit .bat extension