uvx warn when executable is not part of --from PKG (#5071)

## Summary

Resolves #5017.

Note: This re-uses the same function defined in #5019 to find matching
packages.

## Test Plan

`cargo test`

```console
❯ ./target/debug/uvx --from fastapi fastapi
warning: `uvx` is experimental and may change without warning.
Resolved 33 packages in 427ms
warning: The fastapi executable is not part of the fastapi package. It is provided by the fastapi-cli package. Use `uvx --from fastapi-cli fastapi` instead.
Usage: fastapi [OPTIONS] COMMAND [ARGS]...
Try 'fastapi --help' for help.
```

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
Ahmed Ilyas 2024-07-15 21:27:10 +02:00 committed by GitHub
parent 8c0ad5b75e
commit 492e778fe7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 168 additions and 46 deletions

View file

@ -56,7 +56,7 @@ pub(super) fn matching_packages(
environment: &PythonEnvironment, environment: &PythonEnvironment,
) -> anyhow::Result<Vec<InstalledDist>> { ) -> anyhow::Result<Vec<InstalledDist>> {
let site_packages = SitePackages::from_environment(environment)?; let site_packages = SitePackages::from_environment(environment)?;
let entrypoints = site_packages let packages = site_packages
.iter() .iter()
.filter_map(|package| { .filter_map(|package| {
entrypoint_paths(environment, package.name(), package.version()) entrypoint_paths(environment, package.name(), package.version())
@ -75,5 +75,5 @@ pub(super) fn matching_packages(
}) })
.collect(); .collect();
Ok(entrypoints) Ok(packages)
} }

View file

@ -7,6 +7,7 @@ use std::{borrow::Cow, fmt::Display};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use itertools::Itertools; use itertools::Itertools;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use pypi_types::Requirement;
use tokio::process::Command; use tokio::process::Command;
use tracing::{debug, warn}; use tracing::{debug, warn};
@ -23,11 +24,11 @@ use uv_python::{
PythonRequest, PythonRequest,
}; };
use uv_tool::{entrypoint_paths, InstalledTools}; use uv_tool::{entrypoint_paths, InstalledTools};
use uv_warnings::warn_user_once; use uv_warnings::{warn_user, warn_user_once};
use crate::commands::project::environment::CachedEnvironment;
use crate::commands::reporters::PythonDownloadReporter; use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::tool::common::resolve_requirements; use crate::commands::tool::common::resolve_requirements;
use crate::commands::{project::environment::CachedEnvironment, tool::common::matching_packages};
use crate::commands::{ExitStatus, SharedState}; use crate::commands::{ExitStatus, SharedState};
use crate::printer::Printer; use crate::printer::Printer;
use crate::settings::ResolverInstallerSettings; use crate::settings::ResolverInstallerSettings;
@ -71,8 +72,6 @@ pub(crate) async fn run(
warn_user_once!("`{invocation_source}` is experimental and may change without warning."); warn_user_once!("`{invocation_source}` is experimental and may change without warning.");
} }
let has_from = from.is_some();
let (target, args) = command.split(); let (target, args) = command.split();
let Some(target) = target else { let Some(target) = target else {
return Err(anyhow::anyhow!("No tool command provided")); return Err(anyhow::anyhow!("No tool command provided"));
@ -85,7 +84,7 @@ pub(crate) async fn run(
}; };
// Get or create a compatible environment in which to execute the tool. // Get or create a compatible environment in which to execute the tool.
let environment = get_or_create_environment( let (from, environment) = get_or_create_environment(
&from, &from,
&with, &with,
python.as_deref(), python.as_deref(),
@ -101,11 +100,12 @@ pub(crate) async fn run(
printer, printer,
) )
.await?; .await?;
// TODO(zanieb): Determine the command via the package entry points
let command = target; // TODO(zanieb): Determine the executable command via the package entry points
let executable = target;
// Construct the command // Construct the command
let mut process = Command::new(command.as_ref()); let mut process = Command::new(executable.as_ref());
process.args(args); process.args(args);
// Construct the `PATH` environment variable. // Construct the `PATH` environment variable.
@ -136,39 +136,35 @@ pub(crate) async fn run(
let space = if args.is_empty() { "" } else { " " }; let space = if args.is_empty() { "" } else { " " };
debug!( debug!(
"Running `{}{space}{}`", "Running `{}{space}{}`",
command.to_string_lossy(), executable.to_string_lossy(),
args.iter().map(|arg| arg.to_string_lossy()).join(" ") args.iter().map(|arg| arg.to_string_lossy()).join(" ")
); );
// 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.
warn_executable_not_provided_by_package(
&executable.to_string_lossy(),
&from.name,
&environment,
&invocation_source,
);
let mut handle = match process.spawn() { let mut handle = match process.spawn() {
Ok(handle) => Ok(handle), Ok(handle) => Ok(handle),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => { Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
match get_entrypoints(&from, &environment) { match get_entrypoints(&from.name, &environment) {
Ok(entrypoints) => { Ok(entrypoints) => {
if entrypoints.is_empty() { writeln!(
printer.stdout(),
"The executable `{}` was not found.",
executable.to_string_lossy().red(),
)?;
if !entrypoints.is_empty() {
writeln!( writeln!(
printer.stdout(), printer.stdout(),
"The executable {} was not found.", "The following executables are provided by `{}`:",
command.to_string_lossy().red(), &from.name.green()
)?; )?;
} 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!("{invocation_source} --from {from} <EXECUTABLE>");
writeln!(
printer.stdout(),
"However, the following executables are available via {}:",
command.green(),
)?;
}
for (name, _) in entrypoints { for (name, _) in entrypoints {
writeln!(printer.stdout(), "- {}", name.cyan())?; writeln!(printer.stdout(), "- {}", name.cyan())?;
} }
@ -183,7 +179,7 @@ pub(crate) async fn run(
} }
Err(err) => Err(err), Err(err) => Err(err),
} }
.with_context(|| format!("Failed to spawn: `{}`", command.to_string_lossy()))?; .with_context(|| format!("Failed to spawn: `{}`", executable.to_string_lossy()))?;
let status = handle.wait().await.context("Child process disappeared")?; let status = handle.wait().await.context("Child process disappeared")?;
@ -197,11 +193,13 @@ pub(crate) async fn run(
} }
/// Return the entry points for the specified package. /// Return the entry points for the specified package.
fn get_entrypoints(from: &str, environment: &PythonEnvironment) -> Result<Vec<(String, PathBuf)>> { fn get_entrypoints(
from: &PackageName,
environment: &PythonEnvironment,
) -> Result<Vec<(String, PathBuf)>> {
let site_packages = SitePackages::from_environment(environment)?; let site_packages = SitePackages::from_environment(environment)?;
let package = PackageName::from_str(from)?;
let installed = site_packages.get_packages(&package); let installed = site_packages.get_packages(from);
let Some(installed_dist) = installed.first().copied() else { let Some(installed_dist) = installed.first().copied() else {
bail!("Expected at least one requirement") bail!("Expected at least one requirement")
}; };
@ -213,6 +211,62 @@ fn get_entrypoints(from: &str, environment: &PythonEnvironment) -> Result<Vec<(S
)?) )?)
} }
/// 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,
environment: &PythonEnvironment,
invocation_source: &ToolRunCommand,
) {
if let Ok(packages) = matching_packages(executable, environment) {
if !packages
.iter()
.any(|package| package.name() == from_package)
{
match packages.as_slice() {
[] => {
warn_user!(
"A `{}` executable is not provided by package `{}`.",
executable.green(),
from_package.red()
);
}
[package] => {
let suggested_command = format!(
"{invocation_source} --from {} {}",
package.name(),
executable
);
warn_user!(
"A `{}` executable is not provided by package `{}` but is available via the dependency `{}`. Consider using `{}` instead.",
executable.green(),
from_package.red(),
package.name().green(),
suggested_command.cyan()
);
}
packages => {
let suggested_command = format!("{invocation_source} --from PKG {executable}");
let provided_by = packages
.iter()
.map(distribution_types::Name::name)
.map(|name| format!("- {}", name.cyan()))
.join("\n");
warn_user!(
"A `{}` executable is not provided by package `{}` but is available via the following dependencies:\n- {}\nConsider using `{}` instead.",
executable.green(),
from_package.red(),
provided_by,
suggested_command.cyan(),
);
}
}
}
}
}
/// Get or create a [`PythonEnvironment`] in which to run the specified tools. /// 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 /// If the target tool is already installed in a compatible environment, returns that
@ -231,7 +285,7 @@ async fn get_or_create_environment(
native_tls: bool, native_tls: bool,
cache: &Cache, cache: &Cache,
printer: Printer, printer: Printer,
) -> Result<PythonEnvironment> { ) -> Result<(Requirement, PythonEnvironment)> {
let client_builder = BaseClientBuilder::new() let client_builder = BaseClientBuilder::new()
.connectivity(connectivity) .connectivity(connectivity)
.native_tls(native_tls); .native_tls(native_tls);
@ -326,7 +380,7 @@ async fn get_or_create_environment(
Ok(SatisfiesResult::Fresh { .. }) Ok(SatisfiesResult::Fresh { .. })
) { ) {
debug!("Using existing tool `{}`", from.name); debug!("Using existing tool `{}`", from.name);
return Ok(environment); return Ok((from, environment));
} }
} }
} }
@ -348,7 +402,7 @@ async fn get_or_create_environment(
) )
.await?; .await?;
Ok(environment.into()) Ok((from, environment.into()))
} }
/// Parse a target into a command name and a requirement. /// Parse a target into a command name and a requirement.

View file

@ -148,8 +148,8 @@ fn tool_run_at_version() {
success: false success: false
exit_code: 1 exit_code: 1
----- stdout ----- ----- stdout -----
The executable pytest@8.0.0 was not found. The executable `pytest@8.0.0` was not found.
However, the following executables are available: The following executables are provided by `pytest`:
- py.test - py.test
- pytest - pytest
@ -162,6 +162,7 @@ fn tool_run_at_version() {
+ packaging==24.0 + packaging==24.0
+ pluggy==1.4.0 + pluggy==1.4.0
+ pytest==8.1.1 + pytest==8.1.1
warning: A `pytest@8.0.0` executable is not provided by package `pytest`.
"###); "###);
} }
@ -210,8 +211,8 @@ fn tool_run_suggest_valid_commands() {
success: false success: false
exit_code: 1 exit_code: 1
----- stdout ----- ----- stdout -----
The executable orange was not found. The executable `orange` was not found.
However, the following executables are available: The following executables are provided by `black`:
- black - black
- blackd - blackd
@ -226,6 +227,7 @@ fn tool_run_suggest_valid_commands() {
+ packaging==24.0 + packaging==24.0
+ pathspec==0.12.1 + pathspec==0.12.1
+ platformdirs==4.2.0 + platformdirs==4.2.0
warning: A `orange` executable is not provided by package `black`.
"###); "###);
uv_snapshot!(context.filters(), context.tool_run() uv_snapshot!(context.filters(), context.tool_run()
@ -235,7 +237,7 @@ fn tool_run_suggest_valid_commands() {
success: false success: false
exit_code: 1 exit_code: 1
----- stdout ----- ----- stdout -----
The executable fastapi-cli was not found. The executable `fastapi-cli` was not found.
----- stderr ----- ----- stderr -----
warning: `uv tool run` is experimental and may change without warning. warning: `uv tool run` is experimental and may change without warning.
@ -245,6 +247,72 @@ fn tool_run_suggest_valid_commands() {
+ fastapi-cli==0.0.1 + fastapi-cli==0.0.1
+ importlib-metadata==1.7.0 + importlib-metadata==1.7.0
+ zipp==3.18.1 + zipp==3.18.1
warning: A `fastapi-cli` executable is not provided by package `fastapi-cli`.
"###);
}
#[test]
fn tool_run_warn_executable_not_in_from() {
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");
let mut filters = context.filters();
filters.push(("\\+ uvloop(.+)\n ", ""));
// Strip off the `fastapi` command output.
filters.push(("(?s)fastapi` instead.*", "fastapi` instead."));
uv_snapshot!(filters, context.tool_run()
.arg("--from")
.arg("fastapi")
.arg("fastapi")
.env("UV_EXCLUDE_NEWER", "2024-05-04T00:00:00Z") // TODO: Remove this once EXCLUDE_NEWER is bumped past 2024-05-04
// (FastAPI 0.111 is only available from this date onwards)
.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 -----
----- stderr -----
warning: `uv tool run` is experimental and may change without warning.
Resolved 35 packages in [TIME]
Prepared 35 packages in [TIME]
Installed 35 packages in [TIME]
+ annotated-types==0.6.0
+ anyio==4.3.0
+ certifi==2024.2.2
+ click==8.1.7
+ dnspython==2.6.1
+ email-validator==2.1.1
+ fastapi==0.111.0
+ fastapi-cli==0.0.2
+ h11==0.14.0
+ httpcore==1.0.5
+ httptools==0.6.1
+ httpx==0.27.0
+ idna==3.7
+ jinja2==3.1.3
+ markdown-it-py==3.0.0
+ markupsafe==2.1.5
+ mdurl==0.1.2
+ orjson==3.10.3
+ pydantic==2.7.1
+ pydantic-core==2.18.2
+ pygments==2.17.2
+ python-dotenv==1.0.1
+ python-multipart==0.0.9
+ pyyaml==6.0.1
+ rich==13.7.1
+ shellingham==1.5.4
+ sniffio==1.3.1
+ starlette==0.37.2
+ typer==0.12.3
+ typing-extensions==4.11.0
+ ujson==5.9.0
+ uvicorn==0.29.0
+ watchfiles==0.21.0
+ websockets==12.0
warning: A `fastapi` executable is not provided by package `fastapi` but is available via the dependency `fastapi-cli`. Consider using `uv tool run --from fastapi-cli fastapi` instead.
"###); "###);
} }