mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-24 13:43:45 +00:00
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:
parent
6cc2202799
commit
514a7ea6df
2 changed files with 200 additions and 156 deletions
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue