Require the command in uvx <name> to be available in the Python environment (#11603)

Closes https://github.com/astral-sh/uv/issues/7804

Includes a few small minor changes to the messaging, but the primary
change is that in, e.g., `uvx foo`, if the `foo` package does not
provide the `foo` executable we will no longer execute an arbitrary
`foo` executable if present on the `PATH`. This prevents confusing and
surprising behavior, such as the user reported where they did `uv tool
install foobar` (which provides `foo`) then `uvx foo` (which does not
provide `foo`) later falls back to the executable provided by `foobar`
since it's on the `PATH`. We don't enforce this for `--from`, so things
like `uvx --from foo bash -c "..."` are still totally valid. We also
still allow `uvx foo` where the `foo` executable is provided by a
_dependency_ of `foo` instead of `foo` itself.

Most of the diff here is consolidating the logic of the
`hint_on_not_found` and `warn_executable_not_provided_by_package `
utilities.
This commit is contained in:
Zanie Blue 2025-04-18 16:57:33 -05:00
parent 6cc2202799
commit 514a7ea6df
2 changed files with 200 additions and 156 deletions

View file

@ -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<Option<ExitStatus>> {
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 {} <EXECUTABLE_NAME>",
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<InstalledDist>,
/// 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(),
_ => "<EXECUTABLE-NAME>",
};
// 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(())
}
}

View file

@ -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 <EXECUTABLE_NAME>` 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 <EXECUTABLE_NAME>` 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 <EXECUTABLE_NAME>` 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 <EXECUTABLE_NAME>` instead.
----- stderr -----
warning: An executable named `does_not_exist` is not provided by package `foo`.
"###);
// Test with explicit .bat extension